Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f1c15b85 | |||
| f8d03fad4d | |||
| 5b0e41199d |
@@ -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.
|
||||||
+28
-3
@@ -13,6 +13,11 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
|
|||||||
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
|
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
|
||||||
稳定发布与生产部署始终保留人工批准点。
|
稳定发布与生产部署始终保留人工批准点。
|
||||||
|
|
||||||
|
显式调用 `$ack` 也可以直接处理一次性交付:ACK 根据请求把源码发布/发布 PR、DEB、
|
||||||
|
Docker 镜像分别路由给 `manage-release`、`deb-publisher` 或
|
||||||
|
`publish-docker-image`。实际操作由独立 Operator 执行;它使用与 Test 相同的低成本
|
||||||
|
模型和 reasoning effort,但不复用 Test terminal,也不进入 Developer → Test 闭环。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
全局安装:
|
全局安装:
|
||||||
@@ -137,6 +142,23 @@ PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml
|
|||||||
profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。
|
profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。
|
||||||
具体契约见 `references/delivery.md`。
|
具体契约见 `references/delivery.md`。
|
||||||
|
|
||||||
|
## 一次性交付路由
|
||||||
|
|
||||||
|
直接发布时可以说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ack 为 v1.4.0 创建 release PR,合并后打 tag。
|
||||||
|
$ack 把 1.4.0 的 amd64 DEB 发布到 testing 仓库。
|
||||||
|
$ack 把当前提交发布成 registry.example.com/team/app:1.4.0。
|
||||||
|
```
|
||||||
|
|
||||||
|
源码版本生命周期和一次性 PR/MR 路由到 `manage-release`,DEB/APT 路由到
|
||||||
|
`deb-publisher`,Docker/OCI 镜像路由到 `publish-docker-image`。普通 PR/MR 只执行
|
||||||
|
`manage-release` 的 `PR-only` 流程,不会被误当成完整版本发布;功能任务 verified 后
|
||||||
|
按 profile 自动开 PR 时仍使用 ACK 原有的 `pull-request` 动作。一次性交付要求任务板配置
|
||||||
|
`defaults.operator`;当前模板已经提供,并强制其 CLI、standard 模型和 reasoning
|
||||||
|
effort 与 Test default 相同。详见 `references/delivery-routing.md`。
|
||||||
|
|
||||||
## 启动 Worker
|
## 启动 Worker
|
||||||
|
|
||||||
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
||||||
@@ -173,7 +195,8 @@ argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git workt
|
|||||||
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
|
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
|
||||||
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
|
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
|
||||||
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
|
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
|
||||||
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
|
写入任务的 `dispatch.developer`、`dispatch.test` 或 `dispatch.operator`,并同步写入
|
||||||
|
本轮 `attemptId`。
|
||||||
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
|
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
|
||||||
不能跨任务或跨轮次改挂。
|
不能跨任务或跨轮次改挂。
|
||||||
|
|
||||||
@@ -223,5 +246,7 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、
|
|||||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
||||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||||
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
||||||
`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的
|
`project.deliveryFile` 与 `deliveryRuns`;从 `0.12.0` 起,新模板包含与 Test 同档模型的
|
||||||
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
Operator,用于一次性交付路由。旧项目可以不迁移而继续使用原闭环,但补齐 Operator
|
||||||
|
前不能使用一次性交付。旧项目的 `kitVersion` 可以继续读取,但建议迁移为
|
||||||
|
`ackVersion`。
|
||||||
|
|||||||
+51
-8
@@ -3,7 +3,8 @@ name: ack
|
|||||||
description: >-
|
description: >-
|
||||||
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||||
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
||||||
工作,或配置并执行任务验证后的项目交付流程时使用。
|
工作、配置并执行任务验证后的项目交付流程,或把一次性 PR/发布请求路由给
|
||||||
|
manage-release、deb-publisher、publish-docker-image 普通 worker 时使用。
|
||||||
---
|
---
|
||||||
|
|
||||||
# ACK 项目协作入口
|
# ACK 项目协作入口
|
||||||
@@ -22,6 +23,8 @@ description: >-
|
|||||||
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
||||||
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
||||||
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
|
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
|
||||||
|
- 用户直接要求创建发布 PR/MR、发布源码版本、DEB 或 Docker 镜像:执行
|
||||||
|
“一次性交付路由”。
|
||||||
|
|
||||||
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
||||||
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
||||||
@@ -96,14 +99,49 @@ description: >-
|
|||||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
||||||
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
||||||
缺少可选交付配置而宣称失败。
|
缺少可选交付配置而宣称失败。
|
||||||
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
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/operator` 的引用;receipt
|
||||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||||
必须同时为空或同时填写。
|
必须同时为空或同时填写。
|
||||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
`defaults.operator` 对旧项目可选;存在时必须是 standard profile,且 CLI、模型与
|
||||||
|
reasoning effort 必须和 `defaults.test` 相同。缺少它只表示一次性交付路由不可用,
|
||||||
|
不影响原 Developer/Test 闭环。
|
||||||
|
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||||
|
|
||||||
|
## 一次性交付路由
|
||||||
|
|
||||||
|
1. 读取 `references/delivery-routing.md` 并按用户原始请求分类。源码版本、release/hotfix
|
||||||
|
分支、普通或发布 PR/MR、tag 或 Forge Release 使用 `manage-release`;普通 PR/MR
|
||||||
|
只授权它的 `PR-only` 流程,不推断版本升级、合并或 tag。DEB/APT 使用
|
||||||
|
`deb-publisher`;Docker/OCI registry 使用 `publish-docker-image`。
|
||||||
|
2. 若 `docs/ack/tasks.yaml` 不存在,或结构化 orchestration 没有通过校验的
|
||||||
|
`defaults.operator`,停止并建议初始化或按当前模板升级。不要由 Coordinator 亲自
|
||||||
|
执行,也不要猜模型。用户请求有多个合理路由且无法从项目事实唯一确定时,只问一个
|
||||||
|
最小澄清问题。
|
||||||
|
3. 在权威任务板新增 `type: delivery-operation` 的最小操作记录,`operation.skill` 保存
|
||||||
|
精确 Skill 名,`operation.request` 保留本次用户请求的授权语义;若原文含凭据值,
|
||||||
|
必须替换为 `[REDACTED]`,不能落盘。`dispatch.operator` 使用
|
||||||
|
`defaults.operator`。不为一次性交付写 PRD,也不进入 Developer → Test 闭环,
|
||||||
|
不读取或修改 `delivery.yaml`。
|
||||||
|
4. 运行 `validate_tasks.py`,再只用 `launch_worker.py plan|launch --role operator`
|
||||||
|
创建 fresh worker。审阅并绑定 fingerprint/receipt 后,把
|
||||||
|
`references/delivery-routing.md` 的 Operator prompt 和原始请求投递给 worker;
|
||||||
|
Orca 模式同时读取 `references/orca-adapter.md`,登记单一操作任务与 dispatch。
|
||||||
|
Operator 使用与 Test 相同的低成本模型/effort,但它是独立角色和独立终端,不复用
|
||||||
|
Test worker。
|
||||||
|
5. 被选中的低层 Skill 决定实际步骤、确认点、硬停止与恢复。ACK 路由和项目配置都不能
|
||||||
|
扩大用户授权;Docker 路由只因用户显式调用 `$ack` 且明确要求发布镜像才视为对
|
||||||
|
`publish-docker-image` 的显式委派。
|
||||||
|
6. Coordinator 只读 worker 证据并对照低层 Skill 完成标准终检。满足原始请求才把操作
|
||||||
|
标为 `verified`;可恢复的安全停止标为 `blocked`。远端写入部分成功或状态不确定时
|
||||||
|
不自动重试,先按低层 Skill 发现真实状态。
|
||||||
|
|
||||||
## 工作
|
## 工作
|
||||||
|
|
||||||
@@ -121,10 +159,15 @@ description: >-
|
|||||||
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、目标、停止点和需要审批的
|
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
||||||
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
||||||
5. 创建或更换 worker 时,只使用
|
5. 创建或更换 Developer、Test 或 Operator worker 时,只使用
|
||||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
||||||
@@ -178,8 +221,8 @@ description: >-
|
|||||||
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||||
提交、推送、创建终端、新 worktree、发布产物或部署。
|
提交、推送、创建终端、新 worktree、发布产物或部署。
|
||||||
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
||||||
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
|
与 Test 只读,只能通过回报提名或验证知识;Operator 同样只读这些状态文件,只回传
|
||||||
维护中修改。
|
一次性交付证据。`delivery.yaml` 只在显式的交付配置维护中修改。
|
||||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
||||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.11.0
|
0.12.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "ACK"
|
display_name: "ACK"
|
||||||
short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环"
|
short_description: "运行 ACK 开发验证闭环,并路由 PR、源码、DEB 与镜像发布"
|
||||||
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile."
|
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain delivery configuration, run an approved delivery profile, or route a one-off release request to a low-cost operator."
|
||||||
policy:
|
policy:
|
||||||
allow_implicit_invocation: false
|
allow_implicit_invocation: false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ack v0.11.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 指令文件。
|
||||||
|
|||||||
@@ -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.11.0"
|
ackVersion: "0.12.0"
|
||||||
project:
|
project:
|
||||||
name: "notes-web"
|
name: "notes-web"
|
||||||
repoPath: "/home/dev/notes-web"
|
repoPath: "/home/dev/notes-web"
|
||||||
@@ -104,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:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||||
allowlist,默认 profile 与角色/档位一致。
|
allowlist,默认 profile 与角色/档位一致。
|
||||||
|
- [ ] 需要一次性交付路由时存在 `defaults.operator`;其 CLI、standard 模型和
|
||||||
|
reasoning effort 与 `defaults.test` 完全相同。
|
||||||
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
||||||
|
|
||||||
## 路径权限
|
## 路径权限
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||||
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||||
|
- [ ] Operator 只回传一次性交付证据,不写 ACK 项目状态文件。
|
||||||
|
|
||||||
## 任务板
|
## 任务板
|
||||||
|
|
||||||
@@ -58,6 +61,16 @@
|
|||||||
- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`;
|
- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`;
|
||||||
失败不会把已验证任务回退为失败。
|
失败不会把已验证任务回退为失败。
|
||||||
|
|
||||||
|
## 可选一次性交付路由
|
||||||
|
|
||||||
|
- [ ] PR/MR 与源码发布、DEB、Docker 镜像分别路由到 `manage-release`、
|
||||||
|
`deb-publisher`、`publish-docker-image`;普通 PR/MR 只使用 `PR-only`,没有扩大
|
||||||
|
到版本升级、合并、tag 或 Forge Release。
|
||||||
|
- [ ] `delivery-operation.operation.request` 保留用户原始请求,没有扩大授权。
|
||||||
|
- [ ] Operator 使用 `launch_worker.py --role operator` 创建的 fresh worker,没有复用
|
||||||
|
Test terminal,也没有让 Coordinator 代跑。
|
||||||
|
- [ ] 部分成功或外部状态不确定时按低层 Skill 恢复,没有盲目自动重试。
|
||||||
|
|
||||||
## 项目知识
|
## 项目知识
|
||||||
|
|
||||||
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
||||||
@@ -83,13 +96,15 @@
|
|||||||
`orca terminal create --command` 或自由 worker 命令。
|
`orca terminal create --command` 或自由 worker 命令。
|
||||||
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
|
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
|
||||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
- [ ] Developer/Test/Operator 只使用 `read-only` 或 `workspace-write` profile;
|
||||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||||
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
||||||
Orca live metadata 当作自动复用授权。
|
Orca live metadata 当作自动复用授权。
|
||||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||||
|
- [ ] 一次性交付 receipt 引用 `dispatch.operator`,与当前操作、profile 和 attempt
|
||||||
|
完全一致。
|
||||||
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
|
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
|
||||||
- [ ] Test 使用的服务来自正确 worktree。
|
- [ ] Test 使用的服务来自正确 worktree。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ACK 一次性交付路由
|
||||||
|
|
||||||
|
本文件定义用户显式调用 `$ack` 后,直接要求创建发布相关 PR/MR、发布源码版本、
|
||||||
|
上传 DEB 或发布 Docker 镜像时的路由。它不要求先跑 Developer → Test 闭环,也不读取
|
||||||
|
或修改 `delivery.yaml`;项目已有的“verified 后按 profile 交付”仍按 `delivery.md`
|
||||||
|
执行。
|
||||||
|
|
||||||
|
## 1. 只按用户目标选择能力
|
||||||
|
|
||||||
|
| 用户目标 | 路由 | 不得顺带执行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 创建普通或发布 PR/MR,以及版本号、release/hotfix 分支、合并、正式 tag、Forge Release、恢复中断的源码发布 | `manage-release` | 未请求的版本升级、合并、tag、Forge Release、清理、普通产物上传 |
|
||||||
|
| 构建、检查、上传 `.deb`,发布到 APT/DEB 仓库 | `deb-publisher` | tag、Forge Release、Docker 镜像 |
|
||||||
|
| 构建并推送 Docker/OCI 镜像到 registry | `publish-docker-image` | 源码版本、DEB、额外 tag;用户说的 `docker-publisher` 视为这个已安装 Skill 的别名 |
|
||||||
|
|
||||||
|
“创建 PR/MR”路由到 `manage-release`,但必须区分权限上限:发布版本、release/hotfix
|
||||||
|
分支或版本升级对应的 PR/MR 走发布流程;普通 PR/MR 只走它的 ACK `PR-only` 入口,
|
||||||
|
不得推断版本升级、release 分支、合并、tag 或 Forge Release。ACK 功能任务在 verified
|
||||||
|
后按 profile 自动开 PR 时,仍使用 `delivery.md` 的 `pull-request` 动作,不重复建立
|
||||||
|
一次性操作。
|
||||||
|
|
||||||
|
只说“发布一下”时先从用户文字和项目发布入口判断目标。DEB、镜像和源码发布仍有两个
|
||||||
|
以上合理候选时,先问一个最小澄清问题;不要根据最近文件或历史命令猜。用户明确要求
|
||||||
|
多个产物时,为每个能力建立独立操作,按用户给出的顺序执行,不能由一个 Skill 暗中
|
||||||
|
扩大到另一个 Skill。
|
||||||
|
|
||||||
|
## 2. 用低成本 Operator 派发
|
||||||
|
|
||||||
|
一次性交付由 `operator` worker 执行,Coordinator 不亲自运行低层 Skill。机器事实仍
|
||||||
|
只读 `docs/ack/tasks.yaml.project.orchestration`:
|
||||||
|
|
||||||
|
- `defaults.operator` 必须指向 `role: operator`、`tier: standard` 的 profile。
|
||||||
|
- Operator 默认 profile 的 `cli`、`tier`、`model` 和 `reasoningEffort` 必须与
|
||||||
|
`defaults.test` 完全相同;校验器会拒绝漂移。`permissionMode` 仍按项目需要显式写
|
||||||
|
`read-only` 或 `workspace-write`,发布通常需要后者。
|
||||||
|
- 旧项目没有 `defaults.operator` 时,普通 ACK 闭环仍可运行,但一次性交付路由必须
|
||||||
|
fail closed。先按当前模板补 operator allowlist/profile/default 并校验,不能临时猜
|
||||||
|
模型或让 Coordinator 代跑。
|
||||||
|
- launcher 只给 Operator 固定的发布凭据 allowlist:`DEB_SERVER_URL`、`DEB_TOKEN`、
|
||||||
|
`DEB_REPOSITORY`、`DEB_UPLOAD_PATH` 和 `SSH_AUTH_SOCK`;Docker/Forge 使用 HOME
|
||||||
|
中自己的凭据存储。值不写入任务板或 receipt。缺少凭据时由低层 Skill 标记 blocked,
|
||||||
|
不通过 prompt、自由 env 或命令行注入秘密。
|
||||||
|
|
||||||
|
路由后在权威任务板新增一条最小记录:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "DELIVERY-001"
|
||||||
|
type: "delivery-operation"
|
||||||
|
title: "publish one DEB"
|
||||||
|
status: "open"
|
||||||
|
operation:
|
||||||
|
skill: "deb-publisher"
|
||||||
|
request: "<保留用户本次请求的授权语义;凭据值必须替换为 [REDACTED]>"
|
||||||
|
dispatch:
|
||||||
|
operator:
|
||||||
|
profileId: "<defaults.operator>"
|
||||||
|
receiptId: null
|
||||||
|
attemptId: null
|
||||||
|
taskId: null
|
||||||
|
dispatchId: null
|
||||||
|
rounds: []
|
||||||
|
```
|
||||||
|
|
||||||
|
运行 `validate_tasks.py` 后,用 `launch_worker.py plan|launch` 创建 fresh worker,参数
|
||||||
|
使用 `--role operator`、本操作 ID 和 `<operation-id>-A1`。审阅 fingerprint,成功后
|
||||||
|
把 receipt 写入 `workerReceipts` 并与 `dispatch.operator` 精确绑定,再投递下节的
|
||||||
|
prompt。Orca 模式按 `orca-adapter.md` 登记单一操作任务,并把 runtime task/dispatch
|
||||||
|
ID 写入 `dispatch.operator`;不创建 Developer/Test 子任务链。不得复用 Test terminal;
|
||||||
|
“同模型档位”不等于“同角色或同 worker”。
|
||||||
|
|
||||||
|
## 3. Operator 派发 Prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是 ACK 一次性交付 Operator。请在 <worktree> 执行以下原始请求:
|
||||||
|
<operation.request>
|
||||||
|
|
||||||
|
ACK 已选择且只授权你加载:$<operation.skill>
|
||||||
|
先完整读取该 Skill 及它要求的 references,再读取可信 base 上的项目发布规则。
|
||||||
|
|
||||||
|
边界:
|
||||||
|
- 原始请求是授权上限;除已脱敏的凭据值外保持原意,不得因 ACK 路由、项目配置或
|
||||||
|
历史操作扩大远端写权限。
|
||||||
|
- 严格遵守低层 Skill 的确认点、硬停止条件、恢复流程和完成标准。
|
||||||
|
- 不修改 docs/ack/tasks.yaml、knowledge.yaml 或 delivery.yaml。
|
||||||
|
- 不把 token、密码、私钥路径或认证配置写入消息、文件、提交或日志。
|
||||||
|
- 发生部分成功或外部状态不确定时停止自动重试,先按低层 Skill 核对真实状态。
|
||||||
|
|
||||||
|
完成或阻塞后只回传:所选 Skill、实际执行到的阶段、准确目标、非敏感证据、
|
||||||
|
未完成项和安全恢复动作。不要把命令已运行等同于发布已验证。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 状态与完成
|
||||||
|
|
||||||
|
Coordinator 在派发后把操作标为 `dispatched`,只读 Operator 的结构化证据做终检:
|
||||||
|
|
||||||
|
- `manage-release` 必须按其完成标准证明 PR/MR、merged commit、远端 tag 或 Forge
|
||||||
|
Release 中用户实际要求的最远阶段。
|
||||||
|
- `deb-publisher` 必须分别证明包元数据/摘要、上传接受和仓库可见性。
|
||||||
|
- `publish-docker-image` 必须证明完整镜像引用、platform、源 commit 和远端 digest;
|
||||||
|
registry 无法查询时明确限制。
|
||||||
|
|
||||||
|
证据满足原始请求才把操作标为 `verified`;安全停止但可恢复时标为 `blocked` 并记录
|
||||||
|
恢复动作。`worker_done` 不等于完成。涉及远端写入的失败不自动进入第二轮;先由对应
|
||||||
|
低层 Skill 执行真实状态发现和恢复,再由用户决定是否继续。
|
||||||
|
|
||||||
|
Operator receipt 和操作证据写在该任务中,不写入 `deliveryRuns`。一次性交付不会因
|
||||||
|
项目其它任务未 `verified` 而被拦截,也不会触发 `delivery.yaml` 的 profile;两种流程
|
||||||
|
必须保持独立,避免同一请求被重复发布。
|
||||||
@@ -121,3 +121,7 @@ ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总
|
|||||||
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
||||||
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
||||||
调用时,ACK 不能绕过它的触发与授权边界。
|
调用时,ACK 不能绕过它的触发与授权边界。
|
||||||
|
|
||||||
|
用户显式调用 `$ack` 直接要求创建发布 PR/MR、发布源码版本、DEB 或镜像时,走
|
||||||
|
`delivery-routing.md` 的一次性交付流程,不要求其它 ACK 任务先 `verified`,也不创建
|
||||||
|
本文件定义的 `deliveryRuns`。两种流程不能在同一请求中隐式重复执行。
|
||||||
|
|||||||
@@ -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 任务一律保留。
|
||||||
@@ -82,6 +82,9 @@ docs/ack/
|
|||||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||||
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
||||||
自动补交付配置。
|
自动补交付配置。
|
||||||
|
- 从 `0.12.0` 起的新项目 orchestration 模板包含可选一次性交付所需的 Operator;其
|
||||||
|
默认 CLI、standard 模型和 reasoning effort 与 Test default 相同。旧项目不补也能
|
||||||
|
运行原闭环,但不能使用一次性交付路由。
|
||||||
- `updatedAt` 使用当前带时区时间。
|
- `updatedAt` 使用当前带时区时间。
|
||||||
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
||||||
真实值。
|
真实值。
|
||||||
@@ -141,7 +144,8 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
|||||||
- Developer 与 Test 的验证命令可执行。
|
- Developer 与 Test 的验证命令可执行。
|
||||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
- 若配置 Operator,其 default 与 Test 使用相同 CLI、standard 模型和 effort。
|
||||||
|
- 顶层 `workerReceipts` 和 `dispatch.developer/test/operator` 的 task/role/profile/attempt
|
||||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||||
v0.10 不自动复用旧终端。
|
v0.10 不自动复用旧终端。
|
||||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 模型路由(稳定核心)
|
# 模型路由(稳定核心)
|
||||||
|
|
||||||
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
|
本文件是**三角色与可选 Operator 的默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
|
||||||
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
||||||
工作,把机械执行交给较弱模型。
|
工作,把机械执行交给较弱模型。
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
|
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
|
||||||
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
||||||
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
||||||
|
| Operator(一次性交付) | 与 Test 相同的中低模型 | 按低层发布 Skill 执行机械步骤并回传证据,不承担范围决策 |
|
||||||
|
|
||||||
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
|
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
|
||||||
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
|
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
|
||||||
@@ -36,6 +37,7 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),
|
|||||||
|
|
||||||
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
|
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
|
||||||
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
|
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
|
||||||
|
- Operator:执行 `delivery-routing.md` 已选定的发布 Skill,按其停止点记录证据。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
|||||||
|
|
||||||
每个 profile 明确声明:
|
每个 profile 明确声明:
|
||||||
|
|
||||||
- `role`:`developer` 或 `test`;
|
- `role`:`developer`、`test` 或 `operator`;
|
||||||
- `cli`:受支持的 Agent CLI;
|
- `cli`:受支持的 Agent CLI;
|
||||||
- `tier`:角色模型档位;
|
- `tier`:角色模型档位;
|
||||||
- `model`:项目 allowlist 中的精确模型 ID;
|
- `model`:项目 allowlist 中的精确模型 ID;
|
||||||
@@ -83,6 +85,9 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
|||||||
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
||||||
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
||||||
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
||||||
|
`developer` 与 `test` 默认项是 Orca 模式必填;`operator` 对旧项目可选,但一次性
|
||||||
|
交付路由要求它存在。Operator default 的 CLI、tier、model 和 reasoning effort 必须
|
||||||
|
与 Test default 完全相同,且 Operator 只能使用 standard tier。
|
||||||
|
|
||||||
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
||||||
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
||||||
@@ -91,7 +96,8 @@ CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构
|
|||||||
### 安全默认
|
### 安全默认
|
||||||
|
|
||||||
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
||||||
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
|
- Developer、发布 Operator 与需要在工作树生成构建/测试产物的 Test 使用
|
||||||
|
`workspace-write`;项目可
|
||||||
为纯观察型 Test 另建更严格的 `read-only` profile。
|
为纯观察型 Test 另建更严格的 `read-only` profile。
|
||||||
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
|
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
|
||||||
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
||||||
@@ -113,12 +119,12 @@ python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
|
|||||||
|
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
|
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
|
||||||
|
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
|
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
|
||||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||||
```
|
```
|
||||||
@@ -148,11 +154,15 @@ v0.10 的 launcher 还执行以下约束:
|
|||||||
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
|
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
|
||||||
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
|
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
|
||||||
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
|
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
|
||||||
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用
|
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;Developer/Test
|
||||||
`per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据,
|
worker 使用 `per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得
|
||||||
不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、
|
Cursor 凭据,不跨 CLI 透传。Operator 使用固定的
|
||||||
`LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GIT_*` 和调用者 PATH 均不会
|
`per-cli-plus-operator-publish-v1`,在同样的 CLI allowlist 之外只允许
|
||||||
透传给 worker。
|
`DEB_SERVER_URL`、`DEB_TOKEN`、`DEB_REPOSITORY`、`DEB_UPLOAD_PATH` 与
|
||||||
|
`SSH_AUTH_SOCK`。这些值不进入 plan、receipt、任务板或日志;Docker/Forge CLI
|
||||||
|
默认只使用当前账户 HOME 中自己的凭据存储。`DBUS_*`、`DISPLAY`、
|
||||||
|
`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、`LD_*`、`PYTHON*`、`NODE_OPTIONS`、
|
||||||
|
`CODEX_HOME`、`GIT_*`、任意 Docker 密码变量和调用者 PATH 均不会透传给 worker。
|
||||||
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
|
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
|
||||||
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
|
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
|
||||||
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
|
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
|
||||||
@@ -232,7 +242,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可
|
|||||||
|
|
||||||
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
||||||
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
|
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
|
||||||
改动文件清单。
|
改动文件清单和发布目标摘要。
|
||||||
|
|
||||||
这样把昂贵推理挡在重复执行之外。
|
这样把昂贵推理挡在重复执行之外。
|
||||||
|
|
||||||
@@ -240,6 +250,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可
|
|||||||
|
|
||||||
## 一句话
|
## 一句话
|
||||||
|
|
||||||
Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手
|
Coordinator 是脑,Test 是眼,Developer 是手;一次性交付 Operator 是按已选 Skill
|
||||||
默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker
|
操作发布系统的手。Coordinator 用强模型做判断,其余角色默认用安全、较低成本的结构化
|
||||||
都必须经 launcher 产生可核对的 receipt。
|
profile;只有常规闭环卡住时才升级,并且所有 worker 都必须经 launcher 产生可核对的
|
||||||
|
receipt。
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ orca orchestration inbox --limit 20 --json
|
|||||||
```
|
```
|
||||||
|
|
||||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||||
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
|
的活跃编排任务。Developer/Test worker 与一次性交付 Operator 都必须通过本次 launcher
|
||||||
|
新建,不要求预先存在。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项
|
|||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
|||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
||||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||||
```
|
```
|
||||||
@@ -101,6 +102,11 @@ launcher fail closed,不能改用手写命令兜底。模型档位与升级规
|
|||||||
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||||
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
||||||
|
|
||||||
|
一次性交付不创建 Developer/Test 子任务链。按 `delivery-routing.md` 建立一个
|
||||||
|
`delivery-operation`,使用 fresh Operator receipt 中的 handle 创建/登记单一 Orca
|
||||||
|
任务并投递 Operator prompt;把返回的 runtime task/dispatch ID 写入
|
||||||
|
`dispatch.operator`。低层 Skill 的完成或 blocked 回报仍由 Coordinator 读证据后落盘。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 创建父任务
|
## 创建父任务
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
||||||
|
|
||||||
|
**一次性交付 Operator 不进入三角色开发闭环。** 用户显式调用 `$ack` 直接要求发布时,
|
||||||
|
Coordinator 按 `delivery-routing.md` 选择低层 Skill,并把原始请求交给独立 Operator。
|
||||||
|
Operator 与 Test 使用相同低成本模型/effort,但不承担独立复测;它只执行被选 Skill、
|
||||||
|
遵守其授权边界并回传发布证据,且不得写 ACK 状态文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三角色能力清单(SSOT)
|
## 三角色能力清单(SSOT)
|
||||||
@@ -146,6 +151,11 @@ failed_retest(累计 3 轮) -> leftover
|
|||||||
|
|
||||||
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
||||||
|
|
||||||
|
`type: delivery-operation` 使用独立的短状态流:`open -> dispatched -> verified | blocked`。
|
||||||
|
这里的 `verified` 只表示 Coordinator 已按被路由 Skill 的完成标准核对一次性交付证据,
|
||||||
|
不表示代码经过 Test 复测,也不能作为普通功能任务的验证证据。远端部分成功不会自动
|
||||||
|
重派;恢复规则见 `delivery-routing.md`。
|
||||||
|
|
||||||
## 交付状态(与任务状态正交)
|
## 交付状态(与任务状态正交)
|
||||||
|
|
||||||
任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在
|
任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -41,6 +41,7 @@ from validate_tasks import load_document, validate_builtin # noqa: E402
|
|||||||
from worker_profiles import ( # noqa: E402
|
from worker_profiles import ( # noqa: E402
|
||||||
LAUNCH_PROTOCOL_VERSION,
|
LAUNCH_PROTOCOL_VERSION,
|
||||||
canonical_sha256,
|
canonical_sha256,
|
||||||
|
environment_policy_for_role,
|
||||||
profile_hash,
|
profile_hash,
|
||||||
render_worker_argv,
|
render_worker_argv,
|
||||||
validate_routing_document,
|
validate_routing_document,
|
||||||
@@ -48,7 +49,6 @@ from worker_profiles import ( # noqa: E402
|
|||||||
|
|
||||||
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
||||||
RECEIPT_VERSION = 1
|
RECEIPT_VERSION = 1
|
||||||
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
|
||||||
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
|
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
|
||||||
MAX_CONTROL_OUTPUT = 1024 * 1024
|
MAX_CONTROL_OUTPUT = 1024 * 1024
|
||||||
MAX_RECORD_SIZE = 256 * 1024
|
MAX_RECORD_SIZE = 256 * 1024
|
||||||
@@ -82,6 +82,15 @@ WORKER_CREDENTIAL_NAMES = {
|
|||||||
"codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}),
|
"codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}),
|
||||||
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
||||||
}
|
}
|
||||||
|
OPERATOR_CREDENTIAL_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"DEB_REPOSITORY",
|
||||||
|
"DEB_SERVER_URL",
|
||||||
|
"DEB_TOKEN",
|
||||||
|
"DEB_UPLOAD_PATH",
|
||||||
|
"SSH_AUTH_SOCK",
|
||||||
|
}
|
||||||
|
)
|
||||||
INHERITED_ENVIRONMENT_PREFIXES = (
|
INHERITED_ENVIRONMENT_PREFIXES = (
|
||||||
"LC_",
|
"LC_",
|
||||||
)
|
)
|
||||||
@@ -184,13 +193,23 @@ def control_environment() -> dict[str, str]:
|
|||||||
return _sanitized_environment(CONTROL_ENVIRONMENT_NAMES)
|
return _sanitized_environment(CONTROL_ENVIRONMENT_NAMES)
|
||||||
|
|
||||||
|
|
||||||
def worker_environment(cli: str) -> dict[str, str]:
|
def worker_environment(cli: str, role: str | None = None) -> dict[str, str]:
|
||||||
"""Return only the supported CLI's own credentials and common runtime data."""
|
"""Return only the supported CLI's own credentials and common runtime data."""
|
||||||
|
|
||||||
credential_names = WORKER_CREDENTIAL_NAMES.get(cli)
|
credential_names = WORKER_CREDENTIAL_NAMES.get(cli)
|
||||||
if credential_names is None:
|
if credential_names is None:
|
||||||
raise LaunchError(f"不支持的 worker CLI 环境: {cli}")
|
raise LaunchError(f"不支持的 worker CLI 环境: {cli}")
|
||||||
return _sanitized_environment(WORKER_ENVIRONMENT_NAMES | credential_names)
|
if role is not None:
|
||||||
|
try:
|
||||||
|
environment_policy_for_role(role)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LaunchError(f"不支持的 worker role 环境: {role}") from exc
|
||||||
|
role_credentials = (
|
||||||
|
OPERATOR_CREDENTIAL_NAMES if role == "operator" else frozenset()
|
||||||
|
)
|
||||||
|
return _sanitized_environment(
|
||||||
|
WORKER_ENVIRONMENT_NAMES | credential_names | role_credentials
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reject_duplicate_or_separator_args(argv: list[str]) -> None:
|
def reject_duplicate_or_separator_args(argv: list[str]) -> None:
|
||||||
@@ -633,15 +652,20 @@ def build_plan(
|
|||||||
raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符")
|
raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符")
|
||||||
if attempt_id not in {f"{task_id}-A1", f"{task_id}-A2", f"{task_id}-A3"}:
|
if attempt_id not in {f"{task_id}-A1", f"{task_id}-A2", f"{task_id}-A3"}:
|
||||||
raise LaunchError("attempt-id 必须精确为 <task-id>-A1..A3")
|
raise LaunchError("attempt-id 必须精确为 <task-id>-A1..A3")
|
||||||
if role not in {"developer", "test"}:
|
if role not in {"developer", "test", "operator"}:
|
||||||
raise LaunchError("role 必须是 developer 或 test")
|
raise LaunchError("role 必须是 developer、test 或 operator")
|
||||||
if not PROFILE_ID_RE.fullmatch(profile_id):
|
if not PROFILE_ID_RE.fullmatch(profile_id):
|
||||||
raise LaunchError("profile-id 格式非法")
|
raise LaunchError("profile-id 格式非法")
|
||||||
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99:
|
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99:
|
||||||
raise LaunchError("slot 必须是 1..99 的整数")
|
raise LaunchError("slot 必须是 1..99 的整数")
|
||||||
|
|
||||||
project_root, board = load_authoritative_board(project_root_value)
|
project_root, board = load_authoritative_board(project_root_value)
|
||||||
find_task(board, task_id)
|
task = find_task(board, task_id)
|
||||||
|
is_delivery_operation = task.get("type") == "delivery-operation"
|
||||||
|
if role == "operator" and not is_delivery_operation:
|
||||||
|
raise LaunchError("operator 只能用于 delivery-operation 任务")
|
||||||
|
if role != "operator" and is_delivery_operation:
|
||||||
|
raise LaunchError("delivery-operation 任务只能由 operator 执行")
|
||||||
project = board["project"]
|
project = board["project"]
|
||||||
orchestration = project.get("orchestration")
|
orchestration = project.get("orchestration")
|
||||||
if not isinstance(orchestration, dict):
|
if not isinstance(orchestration, dict):
|
||||||
@@ -681,7 +705,7 @@ def build_plan(
|
|||||||
"cliVersion": cli_version,
|
"cliVersion": cli_version,
|
||||||
"argv": argv,
|
"argv": argv,
|
||||||
"argvHash": canonical_sha256(argv),
|
"argvHash": canonical_sha256(argv),
|
||||||
"environmentPolicy": ENVIRONMENT_POLICY,
|
"environmentPolicy": environment_policy_for_role(role),
|
||||||
}
|
}
|
||||||
current_profile_hash = profile_hash(
|
current_profile_hash = profile_hash(
|
||||||
profile,
|
profile,
|
||||||
@@ -705,7 +729,11 @@ def build_plan(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR"
|
cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR"
|
||||||
role_label = "DEV" if role == "developer" else "TEST"
|
role_label = {
|
||||||
|
"developer": "DEV",
|
||||||
|
"test": "TEST",
|
||||||
|
"operator": "OP",
|
||||||
|
}[role]
|
||||||
digest_short = launch_fingerprint.split(":", 1)[-1][:10]
|
digest_short = launch_fingerprint.split(":", 1)[-1][:10]
|
||||||
title = (
|
title = (
|
||||||
f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-"
|
f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-"
|
||||||
@@ -1453,7 +1481,10 @@ def bootstrap_worker(launch_id: str) -> int:
|
|||||||
rebuilt["requested"]["argv"],
|
rebuilt["requested"]["argv"],
|
||||||
shell=False,
|
shell=False,
|
||||||
cwd=rebuilt["worktree"]["path"],
|
cwd=rebuilt["worktree"]["path"],
|
||||||
env=worker_environment(str(rebuilt["requested"]["cli"])),
|
env=worker_environment(
|
||||||
|
str(rebuilt["requested"]["cli"]),
|
||||||
|
str(rebuilt["role"]),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
current_record.update(
|
current_record.update(
|
||||||
state="bootstrap-ready",
|
state="bootstrap-ready",
|
||||||
@@ -1488,7 +1519,11 @@ def add_launch_arguments(parser: argparse.ArgumentParser) -> None:
|
|||||||
parser.add_argument("--project-root", required=True)
|
parser.add_argument("--project-root", required=True)
|
||||||
parser.add_argument("--task-id", required=True)
|
parser.add_argument("--task-id", required=True)
|
||||||
parser.add_argument("--attempt-id", required=True)
|
parser.add_argument("--attempt-id", required=True)
|
||||||
parser.add_argument("--role", required=True, choices=("developer", "test"))
|
parser.add_argument(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
choices=("developer", "test", "operator"),
|
||||||
|
)
|
||||||
parser.add_argument("--profile-id", required=True)
|
parser.add_argument("--profile-id", required=True)
|
||||||
parser.add_argument("--worktree", required=True)
|
parser.add_argument("--worktree", required=True)
|
||||||
parser.add_argument("--slot", type=int, default=1)
|
parser.add_argument("--slot", type=int, default=1)
|
||||||
|
|||||||
@@ -113,14 +113,30 @@ DELIVERY_STATUSES = {
|
|||||||
}
|
}
|
||||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
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",
|
||||||
"worker",
|
"worker",
|
||||||
"developer",
|
"developer",
|
||||||
"test",
|
"test",
|
||||||
|
"operator",
|
||||||
"rounds",
|
"rounds",
|
||||||
}
|
}
|
||||||
|
DELIVERY_OPERATION_FIELDS = {"skill", "request"}
|
||||||
|
DELIVERY_OPERATION_SKILLS = {
|
||||||
|
"manage-release",
|
||||||
|
"deb-publisher",
|
||||||
|
"publish-docker-image",
|
||||||
|
}
|
||||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||||
"kind",
|
"kind",
|
||||||
"title",
|
"title",
|
||||||
@@ -423,11 +439,14 @@ def validate_delivery_runs(
|
|||||||
value: object,
|
value: object,
|
||||||
task_statuses: dict[str, object],
|
task_statuses: dict[str, object],
|
||||||
errors: list[str],
|
errors: list[str],
|
||||||
|
*,
|
||||||
|
delivery_operation_ids: set[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
errors.append("deliveryRuns 必须是列表")
|
errors.append("deliveryRuns 必须是列表")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
excluded_task_ids = delivery_operation_ids or set()
|
||||||
seen_run_ids: set[str] = set()
|
seen_run_ids: set[str] = set()
|
||||||
for index, run in enumerate(value):
|
for index, run in enumerate(value):
|
||||||
where = f"deliveryRuns[{index}]"
|
where = f"deliveryRuns[{index}]"
|
||||||
@@ -467,6 +486,11 @@ def validate_delivery_runs(
|
|||||||
for task_id in task_ids:
|
for task_id in task_ids:
|
||||||
if task_id not in task_statuses:
|
if task_id not in task_statuses:
|
||||||
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
|
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
|
||||||
|
elif task_id in excluded_task_ids:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.taskIds: deliveryRuns 不能引用 delivery-operation "
|
||||||
|
f"{task_id!r}"
|
||||||
|
)
|
||||||
elif task_statuses[task_id] != "verified":
|
elif task_statuses[task_id] != "verified":
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{where}: delivery run 只能引用 verified 任务,"
|
f"{where}: delivery run 只能引用 verified 任务,"
|
||||||
@@ -654,6 +678,28 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
{"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"
|
||||||
@@ -710,6 +756,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):
|
||||||
@@ -756,6 +803,49 @@ 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: 必须是非空字符串")
|
||||||
|
|
||||||
|
operation = task.get("operation")
|
||||||
|
if operation is not None:
|
||||||
|
if not isinstance(operation, dict):
|
||||||
|
errors.append(f"{where}.operation: 必须是对象")
|
||||||
|
else:
|
||||||
|
reject_unknown_fields(
|
||||||
|
operation,
|
||||||
|
DELIVERY_OPERATION_FIELDS,
|
||||||
|
f"{where}.operation",
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
skill = operation.get("skill")
|
||||||
|
if skill not in DELIVERY_OPERATION_SKILLS:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.operation.skill: 必须是 "
|
||||||
|
"manage-release/deb-publisher/publish-docker-image"
|
||||||
|
)
|
||||||
|
if not _nonempty_string(operation.get("request")):
|
||||||
|
errors.append(f"{where}.operation.request: 必须保留非空用户请求")
|
||||||
|
|
||||||
|
if task.get("type") == "delivery-operation":
|
||||||
|
if not isinstance(operation, dict):
|
||||||
|
errors.append(f"{where}: delivery-operation 必须声明 operation")
|
||||||
|
|
||||||
validate_knowledge_fields(task, where, status, errors)
|
validate_knowledge_fields(task, where, status, errors)
|
||||||
|
|
||||||
if "dispatch" not in task:
|
if "dispatch" not in task:
|
||||||
@@ -778,6 +868,12 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if task.get("type") == "delivery-operation" and (
|
||||||
|
not isinstance(dispatch, dict)
|
||||||
|
or not isinstance(dispatch.get("operator"), dict)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: delivery-operation 必须声明 dispatch.operator")
|
||||||
|
|
||||||
rounds = dispatch.get("rounds", [])
|
rounds = dispatch.get("rounds", [])
|
||||||
if not isinstance(rounds, list):
|
if not isinstance(rounds, list):
|
||||||
errors.append(f"{where}.dispatch.rounds: 必须是列表")
|
errors.append(f"{where}.dispatch.rounds: 必须是列表")
|
||||||
@@ -878,7 +974,19 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
for task in tasks
|
for task in tasks
|
||||||
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||||
}
|
}
|
||||||
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
delivery_operation_ids = {
|
||||||
|
task["id"]
|
||||||
|
for task in tasks
|
||||||
|
if isinstance(task, dict)
|
||||||
|
and _nonempty_string(task.get("id"))
|
||||||
|
and task.get("type") == "delivery-operation"
|
||||||
|
}
|
||||||
|
validate_delivery_runs(
|
||||||
|
data["deliveryRuns"],
|
||||||
|
task_statuses,
|
||||||
|
errors,
|
||||||
|
delivery_operation_ids=delivery_operation_ids,
|
||||||
|
)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,22 @@ RECEIPT_VERSION = 1
|
|||||||
LAUNCH_PROTOCOL_VERSION = 1
|
LAUNCH_PROTOCOL_VERSION = 1
|
||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3
|
||||||
|
|
||||||
ROLES = frozenset({"developer", "test"})
|
ROLES = frozenset({"developer", "test", "operator"})
|
||||||
|
REQUIRED_DEFAULT_ROLES = frozenset({"developer", "test"})
|
||||||
|
STANDARD_ONLY_ROLES = frozenset({"test", "operator"})
|
||||||
CLIS = frozenset({"codex", "cursor-agent"})
|
CLIS = frozenset({"codex", "cursor-agent"})
|
||||||
TIERS = frozenset({"standard", "strong"})
|
TIERS = frozenset({"standard", "strong"})
|
||||||
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||||
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
||||||
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
||||||
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
|
BASE_ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
||||||
|
OPERATOR_ENVIRONMENT_POLICY = "per-cli-plus-operator-publish-v1"
|
||||||
|
ENVIRONMENT_POLICIES = frozenset(
|
||||||
|
{BASE_ENVIRONMENT_POLICY, OPERATOR_ENVIRONMENT_POLICY}
|
||||||
|
)
|
||||||
|
DEFAULT_KEYS = frozenset(
|
||||||
|
{"developer", "test", "operator", "developerUpgraded"}
|
||||||
|
)
|
||||||
|
|
||||||
ORCHESTRATION_FIELDS = frozenset(
|
ORCHESTRATION_FIELDS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -184,6 +193,16 @@ def _is_positive_int(value: Any) -> bool:
|
|||||||
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
||||||
|
|
||||||
|
|
||||||
|
def environment_policy_for_role(role: Any) -> str:
|
||||||
|
"""Return the fixed credential policy for a validated worker role."""
|
||||||
|
|
||||||
|
if role == "operator":
|
||||||
|
return OPERATOR_ENVIRONMENT_POLICY
|
||||||
|
if role in {"developer", "test"}:
|
||||||
|
return BASE_ENVIRONMENT_POLICY
|
||||||
|
raise ValueError("role must be developer/test/operator")
|
||||||
|
|
||||||
|
|
||||||
def _is_timestamp(value: Any) -> bool:
|
def _is_timestamp(value: Any) -> bool:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return False
|
return False
|
||||||
@@ -211,7 +230,7 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
permission = profile.get("permissionMode")
|
permission = profile.get("permissionMode")
|
||||||
|
|
||||||
if not isinstance(role, str) or role not in ROLES:
|
if not isinstance(role, str) or role not in ROLES:
|
||||||
errors.append(f"{where}.role: must be developer/test")
|
errors.append(f"{where}.role: must be developer/test/operator")
|
||||||
if not isinstance(cli, str) or cli not in CLIS:
|
if not isinstance(cli, str) or cli not in CLIS:
|
||||||
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
||||||
if not isinstance(tier, str) or tier not in TIERS:
|
if not isinstance(tier, str) or tier not in TIERS:
|
||||||
@@ -231,8 +250,13 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
elif cli == "cursor-agent" and effort is not None:
|
elif cli == "cursor-agent" and effort is not None:
|
||||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||||
|
|
||||||
if role == "test" and tier != "standard":
|
if (
|
||||||
errors.append(f"{where}.tier: Test may only use standard")
|
isinstance(role, str)
|
||||||
|
and role in STANDARD_ONLY_ROLES
|
||||||
|
and tier != "standard"
|
||||||
|
):
|
||||||
|
label = "Test" if role == "test" else "Operator"
|
||||||
|
errors.append(f"{where}.tier: {label} may only use standard")
|
||||||
if tier == "strong" and role != "developer":
|
if tier == "strong" and role != "developer":
|
||||||
errors.append(f"{where}.tier: strong may only be used by Developer")
|
errors.append(f"{where}.tier: strong may only be used by Developer")
|
||||||
|
|
||||||
@@ -268,8 +292,11 @@ def _validate_model_allowlist(value: Any, where: str) -> list[str]:
|
|||||||
errors.append(f"{role_where}: must not be empty")
|
errors.append(f"{role_where}: must not be empty")
|
||||||
for tier in sorted(set(tiers) - TIERS, key=repr):
|
for tier in sorted(set(tiers) - TIERS, key=repr):
|
||||||
errors.append(f"{role_where}: unknown tier {tier!r}")
|
errors.append(f"{role_where}: unknown tier {tier!r}")
|
||||||
if role == "test" and "strong" in tiers:
|
if role in STANDARD_ONLY_ROLES and "strong" in tiers:
|
||||||
errors.append(f"{role_where}: Test cannot define a strong allowlist")
|
label = "Test" if role == "test" else "Operator"
|
||||||
|
errors.append(
|
||||||
|
f"{role_where}: {label} cannot define a strong allowlist"
|
||||||
|
)
|
||||||
for tier, models in tiers.items():
|
for tier, models in tiers.items():
|
||||||
tier_where = f"{role_where}.{tier}"
|
tier_where = f"{role_where}.{tier}"
|
||||||
if tier not in TIERS:
|
if tier not in TIERS:
|
||||||
@@ -383,7 +410,7 @@ def validate_orchestration(
|
|||||||
for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr):
|
for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr):
|
||||||
errors.append(f"{where}.defaults: unknown key {default_key!r}")
|
errors.append(f"{where}.defaults: unknown key {default_key!r}")
|
||||||
if mode == "orca":
|
if mode == "orca":
|
||||||
for role in sorted(ROLES - set(defaults)):
|
for role in sorted(REQUIRED_DEFAULT_ROLES - set(defaults)):
|
||||||
errors.append(f"{where}.defaults: missing role {role!r}")
|
errors.append(f"{where}.defaults: missing role {role!r}")
|
||||||
for default_key, profile_id in defaults.items():
|
for default_key, profile_id in defaults.items():
|
||||||
default_where = f"{where}.defaults.{default_key}"
|
default_where = f"{where}.defaults.{default_key}"
|
||||||
@@ -407,6 +434,22 @@ def validate_orchestration(
|
|||||||
if profile.get("permissionMode") not in PERMISSION_MODES:
|
if profile.get("permissionMode") not in PERMISSION_MODES:
|
||||||
errors.append(f"{default_where}: default profile has unsafe permissions")
|
errors.append(f"{default_where}: default profile has unsafe permissions")
|
||||||
|
|
||||||
|
operator_profile_id = defaults.get("operator")
|
||||||
|
test_profile_id = defaults.get("test")
|
||||||
|
operator_profile = valid_profiles.get(operator_profile_id)
|
||||||
|
test_profile = valid_profiles.get(test_profile_id)
|
||||||
|
if operator_profile_id is not None and test_profile is None:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.defaults.operator: requires a valid Test default profile"
|
||||||
|
)
|
||||||
|
elif operator_profile is not None and test_profile is not None:
|
||||||
|
for field in ("cli", "tier", "model", "reasoningEffort"):
|
||||||
|
if operator_profile.get(field) != test_profile.get(field):
|
||||||
|
errors.append(
|
||||||
|
f"{where}.defaults.operator: operator default must use "
|
||||||
|
f"the Test default {field}"
|
||||||
|
)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -502,7 +545,7 @@ def _validate_created_for(value: Any, where: str) -> list[str]:
|
|||||||
errors.append(f"{where}.attemptId: must belong to taskId")
|
errors.append(f"{where}.attemptId: must belong to taskId")
|
||||||
role = value.get("role")
|
role = value.get("role")
|
||||||
if not isinstance(role, str) or role not in ROLES:
|
if not isinstance(role, str) or role not in ROLES:
|
||||||
errors.append(f"{where}.role: must be developer/test")
|
errors.append(f"{where}.role: must be developer/test/operator")
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -571,9 +614,10 @@ def _validate_requested(value: Any, where: str) -> list[str]:
|
|||||||
elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv):
|
elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv):
|
||||||
if argv_hash != canonical_sha256(argv):
|
if argv_hash != canonical_sha256(argv):
|
||||||
errors.append(f"{where}.argvHash: does not match argv")
|
errors.append(f"{where}.argvHash: does not match argv")
|
||||||
if value.get("environmentPolicy") != "per-cli-allowlist-v1":
|
if value.get("environmentPolicy") not in ENVIRONMENT_POLICIES:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1'"
|
f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1' or "
|
||||||
|
"'per-cli-plus-operator-publish-v1'"
|
||||||
)
|
)
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
@@ -722,6 +766,19 @@ def validate_worker_receipt(
|
|||||||
if binding.get("observedWorktreePath") != worktree.get("path"):
|
if binding.get("observedWorktreePath") != worktree.get("path"):
|
||||||
errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path")
|
errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path")
|
||||||
|
|
||||||
|
if isinstance(created_for, dict) and isinstance(requested, dict):
|
||||||
|
try:
|
||||||
|
expected_environment_policy = environment_policy_for_role(
|
||||||
|
created_for.get("role")
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if requested.get("environmentPolicy") != expected_environment_policy:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.requested.environmentPolicy: does not match role"
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isinstance(created_for, dict)
|
isinstance(created_for, dict)
|
||||||
and isinstance(worktree, dict)
|
and isinstance(worktree, dict)
|
||||||
@@ -977,7 +1034,10 @@ __all__ = [
|
|||||||
"RECEIPT_VERSION",
|
"RECEIPT_VERSION",
|
||||||
"LAUNCH_PROTOCOL_VERSION",
|
"LAUNCH_PROTOCOL_VERSION",
|
||||||
"MAX_ROUNDS",
|
"MAX_ROUNDS",
|
||||||
|
"BASE_ENVIRONMENT_POLICY",
|
||||||
|
"OPERATOR_ENVIRONMENT_POLICY",
|
||||||
"canonical_sha256",
|
"canonical_sha256",
|
||||||
|
"environment_policy_for_role",
|
||||||
"profile_hash",
|
"profile_hash",
|
||||||
"receipt_hash",
|
"receipt_hash",
|
||||||
"render_worker_argv",
|
"render_worker_argv",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||||
- 验证后交付与配置维护(可选):`references/delivery.md`
|
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||||
|
- 一次性源码/DEB/镜像发布路由(可选):`references/delivery-routing.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
|------|------------|------|
|
|------|------------|------|
|
||||||
| Developer | `codex-dev-standard` | standard |
|
| Developer | `codex-dev-standard` | standard |
|
||||||
| Test | `codex-test-standard` | standard |
|
| Test | `codex-test-standard` | standard |
|
||||||
|
| Operator | `codex-operator-standard` | standard(模型/effort 与 Test 相同) |
|
||||||
| Developer 升级 | `codex-dev-strong` | strong |
|
| Developer 升级 | `codex-dev-strong` | strong |
|
||||||
|
|
||||||
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||||
@@ -102,6 +104,8 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
||||||
|
- 一次性交付 Operator 不进入三角色闭环;它使用 Test 同档模型执行被路由的低层 Skill,
|
||||||
|
只回传证据,不写 ACK 状态文件。
|
||||||
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
|
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
|
||||||
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
|
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
|
||||||
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
|
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"const": "docs/ack/delivery.yaml",
|
"const": "docs/ack/delivery.yaml",
|
||||||
"description": "可选项目交付契约的唯一权威路径"
|
"description": "可选项目交付契约的唯一权威路径"
|
||||||
},
|
},
|
||||||
|
"bugIntake": { "$ref": "#/definitions/feishuBugIntake" },
|
||||||
"orchestration": {
|
"orchestration": {
|
||||||
"$ref": "#/definitions/orchestration"
|
"$ref": "#/definitions/orchestration"
|
||||||
}
|
}
|
||||||
@@ -164,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}$"
|
||||||
@@ -204,7 +249,8 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"developer": { "$ref": "#/definitions/modelTierAllowlist" },
|
"developer": { "$ref": "#/definitions/modelTierAllowlist" },
|
||||||
"test": { "$ref": "#/definitions/modelTierAllowlist" }
|
"test": { "$ref": "#/definitions/modelTierAllowlist" },
|
||||||
|
"operator": { "$ref": "#/definitions/modelTierAllowlist" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelAllowlist": {
|
"modelAllowlist": {
|
||||||
@@ -229,7 +275,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["developer", "test"]
|
"enum": ["developer", "test", "operator"]
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -259,6 +305,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"developer": { "$ref": "#/definitions/profileId" },
|
"developer": { "$ref": "#/definitions/profileId" },
|
||||||
"test": { "$ref": "#/definitions/profileId" },
|
"test": { "$ref": "#/definitions/profileId" },
|
||||||
|
"operator": { "$ref": "#/definitions/profileId" },
|
||||||
"developerUpgraded": { "$ref": "#/definitions/profileId" }
|
"developerUpgraded": { "$ref": "#/definitions/profileId" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -437,7 +484,10 @@
|
|||||||
},
|
},
|
||||||
"environmentPolicy": {
|
"environmentPolicy": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "per-cli-allowlist-v1"
|
"enum": [
|
||||||
|
"per-cli-allowlist-v1",
|
||||||
|
"per-cli-plus-operator-publish-v1"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -546,7 +596,7 @@
|
|||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["developer", "test"]
|
"enum": ["developer", "test", "operator"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -909,6 +959,22 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"deliveryOperation": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["skill", "request"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"skill": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"manage-release",
|
||||||
|
"deb-publisher",
|
||||||
|
"publish-docker-image"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"request": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"task": {
|
"task": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "title", "status"],
|
"required": ["id", "title", "status"],
|
||||||
@@ -940,10 +1006,21 @@
|
|||||||
"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" },
|
||||||
"actual": { "type": "string" },
|
"actual": { "type": "string" },
|
||||||
|
"operation": { "$ref": "#/definitions/deliveryOperation" },
|
||||||
"evidence": { "type": "object" },
|
"evidence": { "type": "object" },
|
||||||
"verification": { "type": "object" },
|
"verification": { "type": "object" },
|
||||||
"dispatch": {
|
"dispatch": {
|
||||||
@@ -955,6 +1032,7 @@
|
|||||||
"worker": { "type": ["string", "null"] },
|
"worker": { "type": ["string", "null"] },
|
||||||
"developer": { "$ref": "#/definitions/roleDispatch" },
|
"developer": { "$ref": "#/definitions/roleDispatch" },
|
||||||
"test": { "$ref": "#/definitions/roleDispatch" },
|
"test": { "$ref": "#/definitions/roleDispatch" },
|
||||||
|
"operator": { "$ref": "#/definitions/roleDispatch" },
|
||||||
"rounds": {
|
"rounds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/round" }
|
"items": { "$ref": "#/definitions/round" }
|
||||||
@@ -992,6 +1070,21 @@
|
|||||||
},
|
},
|
||||||
"required": ["resolution"]
|
"required": ["resolution"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": { "type": { "const": "delivery-operation" } },
|
||||||
|
"required": ["type"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["operation", "dispatch"],
|
||||||
|
"properties": {
|
||||||
|
"dispatch": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["operator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ project:
|
|||||||
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"
|
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"
|
||||||
@@ -23,11 +39,15 @@ project:
|
|||||||
strong: ["gpt-5.6-sol"]
|
strong: ["gpt-5.6-sol"]
|
||||||
test:
|
test:
|
||||||
standard: ["gpt-5.6-luna"]
|
standard: ["gpt-5.6-luna"]
|
||||||
|
operator:
|
||||||
|
standard: ["gpt-5.6-luna"]
|
||||||
cursor-agent:
|
cursor-agent:
|
||||||
developer:
|
developer:
|
||||||
standard: ["auto"]
|
standard: ["auto"]
|
||||||
test:
|
test:
|
||||||
standard: ["auto"]
|
standard: ["auto"]
|
||||||
|
operator:
|
||||||
|
standard: ["auto"]
|
||||||
profiles:
|
profiles:
|
||||||
codex-dev-standard:
|
codex-dev-standard:
|
||||||
role: "developer"
|
role: "developer"
|
||||||
@@ -43,6 +63,13 @@ project:
|
|||||||
model: "gpt-5.6-luna"
|
model: "gpt-5.6-luna"
|
||||||
reasoningEffort: "low"
|
reasoningEffort: "low"
|
||||||
permissionMode: "workspace-write"
|
permissionMode: "workspace-write"
|
||||||
|
codex-operator-standard:
|
||||||
|
role: "operator"
|
||||||
|
cli: "codex"
|
||||||
|
tier: "standard"
|
||||||
|
model: "gpt-5.6-luna"
|
||||||
|
reasoningEffort: "low"
|
||||||
|
permissionMode: "workspace-write"
|
||||||
codex-dev-strong:
|
codex-dev-strong:
|
||||||
role: "developer"
|
role: "developer"
|
||||||
cli: "codex"
|
cli: "codex"
|
||||||
@@ -64,9 +91,17 @@ project:
|
|||||||
model: "auto"
|
model: "auto"
|
||||||
reasoningEffort: null
|
reasoningEffort: null
|
||||||
permissionMode: "workspace-write"
|
permissionMode: "workspace-write"
|
||||||
|
cursor-operator-standard:
|
||||||
|
role: "operator"
|
||||||
|
cli: "cursor-agent"
|
||||||
|
tier: "standard"
|
||||||
|
model: "auto"
|
||||||
|
reasoningEffort: null
|
||||||
|
permissionMode: "workspace-write"
|
||||||
defaults:
|
defaults:
|
||||||
developer: "codex-dev-standard"
|
developer: "codex-dev-standard"
|
||||||
test: "codex-test-standard"
|
test: "codex-test-standard"
|
||||||
|
operator: "codex-operator-standard"
|
||||||
developerUpgraded: "codex-dev-strong"
|
developerUpgraded: "codex-dev-strong"
|
||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
@@ -95,6 +130,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.>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
|
|
||||||
## 常见用法
|
## 常见用法
|
||||||
|
|
||||||
|
### 通过 ACK 只创建普通 PR
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ack 把当前分支推送并创建一个指向 main 的 PR,不要合并。
|
||||||
|
```
|
||||||
|
|
||||||
|
ACK 会把这类一次性请求路由到 `manage-release` 的 PR-only 流程。Agent 只核对当前
|
||||||
|
head、base、remote、提交边界、验证和 Forge 账号,然后创建或复用 PR;不会顺带升级
|
||||||
|
版本、创建 release 分支、合并、打 tag 或创建 Forge Release。
|
||||||
|
|
||||||
### 确定下一个版本号
|
### 确定下一个版本号
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -106,5 +116,6 @@ Agent 会先发现项目自己的版本、分支和发布规则,再检查本
|
|||||||
## 不适用的场景
|
## 不适用的场景
|
||||||
|
|
||||||
普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包
|
普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包
|
||||||
不需要触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布 PR/MR
|
不需要直接触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布
|
||||||
或 release tag 时,才交给这个 Skill。
|
PR/MR 或 release tag,或者用户显式调用 `$ack` 要求 PR-only 委派时,才交给这个
|
||||||
|
Skill。
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ description: >-
|
|||||||
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
|
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
|
||||||
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
|
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
|
||||||
DEB/Docker 等产物、管理仓库权限时不使用。
|
DEB/Docker 等产物、管理仓库权限时不使用。
|
||||||
|
由显式调用的 `$ack` 根据用户明确的普通 PR/MR 请求路由时,可只执行本文的
|
||||||
|
PR-only 流程,不把它扩大成版本发布。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Manage Release
|
# Manage Release
|
||||||
@@ -28,6 +30,18 @@ description: >-
|
|||||||
|
|
||||||
## 授权边界
|
## 授权边界
|
||||||
|
|
||||||
|
### ACK PR-only 委派
|
||||||
|
|
||||||
|
当且仅当用户显式调用 `$ack`,且 ACK 把用户原始的普通 PR/MR 请求委派给本 Skill 时,
|
||||||
|
使用 PR-only 流程:执行“发现项目规则和当前状态”,锁定准确 head、base、remote、
|
||||||
|
worktree、验证命令与活动 Forge 账号,然后只执行“推送并创建 PR/MR”及其完成检查。
|
||||||
|
用户没有明确要求时,不创建 release 分支、不修改版本或 CHANGELOG、不合并、不打 tag、
|
||||||
|
不创建 Forge Release、不清理分支或 worktree。缺少 head/base/remote、提交边界或平台
|
||||||
|
认证时按硬停止条件报告,不把普通 PR 伪装成发布。
|
||||||
|
|
||||||
|
ACK 只负责路由,不增加授权。Operator 收到的原始请求仍是权限上限;“创建 PR”只授权
|
||||||
|
为准确 head/base 创建或复用 PR,不自动授权合并。
|
||||||
|
|
||||||
按用户明确要求执行到对应阶段:
|
按用户明确要求执行到对应阶段:
|
||||||
|
|
||||||
- 分析版本或检查状态:只执行读取和计算,不修改文件或远端。
|
- 分析版本或检查状态:只执行读取和计算,不修改文件或远端。
|
||||||
@@ -46,6 +60,9 @@ description: >-
|
|||||||
|
|
||||||
## 工作流
|
## 工作流
|
||||||
|
|
||||||
|
PR-only 仍按以下章节做实时状态发现与安全检查,但跳过与用户请求无关的版本计算、
|
||||||
|
release worktree、版本文件、合并和 tag 阶段。已有用户工作不得被 stash、移动或覆盖。
|
||||||
|
|
||||||
### 1. 发现项目规则和当前状态
|
### 1. 发现项目规则和当前状态
|
||||||
|
|
||||||
从项目根目录开始:
|
从项目根目录开始:
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: publish-docker-image
|
name: publish-docker-image
|
||||||
description: >-
|
description: >-
|
||||||
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
||||||
$publish-docker-image 或明确说“使用 publish-docker-image skill”时使用;
|
$publish-docker-image、明确说“使用 publish-docker-image skill”,或由显式调用的 `$ack`
|
||||||
|
根据用户明确的 Docker/OCI 镜像发布请求路由时使用;
|
||||||
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ACK_DIR = REPO_ROOT / "skills" / "ack"
|
||||||
|
SCRIPTS_DIR = ACK_DIR / "scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import launch_worker # noqa: E402
|
||||||
|
import validate_tasks # noqa: E402
|
||||||
|
import worker_profiles # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def operator_orchestration() -> dict:
|
||||||
|
return {
|
||||||
|
"profileVersion": 1,
|
||||||
|
"mode": "orca",
|
||||||
|
"allowedWorktrees": ["/repo/demo"],
|
||||||
|
"modelAllowlist": {
|
||||||
|
"codex": {
|
||||||
|
"developer": {"standard": ["gpt-dev"]},
|
||||||
|
"test": {"standard": ["gpt-low"]},
|
||||||
|
"operator": {"standard": ["gpt-low"]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"codex-dev-standard": {
|
||||||
|
"role": "developer",
|
||||||
|
"cli": "codex",
|
||||||
|
"tier": "standard",
|
||||||
|
"model": "gpt-dev",
|
||||||
|
"reasoningEffort": "medium",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
},
|
||||||
|
"codex-test-standard": {
|
||||||
|
"role": "test",
|
||||||
|
"cli": "codex",
|
||||||
|
"tier": "standard",
|
||||||
|
"model": "gpt-low",
|
||||||
|
"reasoningEffort": "low",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
},
|
||||||
|
"codex-operator-standard": {
|
||||||
|
"role": "operator",
|
||||||
|
"cli": "codex",
|
||||||
|
"tier": "standard",
|
||||||
|
"model": "gpt-low",
|
||||||
|
"reasoningEffort": "low",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"developer": "codex-dev-standard",
|
||||||
|
"test": "codex-test-standard",
|
||||||
|
"operator": "codex-operator-standard",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def routed_board() -> dict:
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"ackVersion": "0.12.0",
|
||||||
|
"project": {
|
||||||
|
"name": "demo",
|
||||||
|
"orchestration": operator_orchestration(),
|
||||||
|
},
|
||||||
|
"workerReceipts": [],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "DELIVERY-001",
|
||||||
|
"type": "delivery-operation",
|
||||||
|
"title": "publish one DEB",
|
||||||
|
"status": "open",
|
||||||
|
"operation": {
|
||||||
|
"skill": "deb-publisher",
|
||||||
|
"request": "发布 1.2.3 的 amd64 DEB 到 testing 仓库",
|
||||||
|
},
|
||||||
|
"dispatch": {
|
||||||
|
"operator": {
|
||||||
|
"profileId": "codex-operator-standard",
|
||||||
|
"receiptId": None,
|
||||||
|
"attemptId": None,
|
||||||
|
"taskId": None,
|
||||||
|
"dispatchId": None,
|
||||||
|
},
|
||||||
|
"rounds": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AckDeliveryRoutingTests(unittest.TestCase):
|
||||||
|
def test_operator_profile_uses_the_test_low_cost_model(self) -> None:
|
||||||
|
routing = operator_orchestration()
|
||||||
|
|
||||||
|
self.assertEqual(worker_profiles.validate_orchestration(routing), [])
|
||||||
|
|
||||||
|
routing["profiles"]["codex-operator-standard"]["model"] = "gpt-other"
|
||||||
|
routing["modelAllowlist"]["codex"]["operator"]["standard"] = [
|
||||||
|
"gpt-other"
|
||||||
|
]
|
||||||
|
errors = worker_profiles.validate_orchestration(routing)
|
||||||
|
self.assertTrue(
|
||||||
|
any("operator default must use the Test default model" in error for error in errors),
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_operator_is_standard_only_and_optional_for_legacy_projects(self) -> None:
|
||||||
|
routing = operator_orchestration()
|
||||||
|
operator = routing["profiles"]["codex-operator-standard"]
|
||||||
|
operator["tier"] = "strong"
|
||||||
|
routing["modelAllowlist"]["codex"]["operator"] = {
|
||||||
|
"strong": ["gpt-low"]
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = worker_profiles.validate_orchestration(routing)
|
||||||
|
self.assertTrue(any("Operator may only use standard" in error for error in errors))
|
||||||
|
|
||||||
|
legacy = operator_orchestration()
|
||||||
|
del legacy["defaults"]["operator"]
|
||||||
|
del legacy["profiles"]["codex-operator-standard"]
|
||||||
|
del legacy["modelAllowlist"]["codex"]["operator"]
|
||||||
|
self.assertEqual(worker_profiles.validate_orchestration(legacy), [])
|
||||||
|
|
||||||
|
def test_delivery_operation_requires_a_supported_route_and_operator_dispatch(self) -> None:
|
||||||
|
board = routed_board()
|
||||||
|
self.assertEqual(validate_tasks.validate_builtin(board), [])
|
||||||
|
|
||||||
|
missing_operation = copy.deepcopy(board)
|
||||||
|
del missing_operation["tasks"][0]["operation"]
|
||||||
|
errors = validate_tasks.validate_builtin(missing_operation)
|
||||||
|
self.assertTrue(any("delivery-operation 必须声明 operation" in error for error in errors))
|
||||||
|
|
||||||
|
unsupported = copy.deepcopy(board)
|
||||||
|
unsupported["tasks"][0]["operation"]["skill"] = "shell"
|
||||||
|
errors = validate_tasks.validate_builtin(unsupported)
|
||||||
|
self.assertTrue(any("manage-release/deb-publisher/publish-docker-image" in error for error in errors))
|
||||||
|
|
||||||
|
missing_dispatch = copy.deepcopy(board)
|
||||||
|
del missing_dispatch["tasks"][0]["dispatch"]["operator"]
|
||||||
|
errors = validate_tasks.validate_builtin(missing_dispatch)
|
||||||
|
self.assertTrue(any("delivery-operation 必须声明 dispatch.operator" in error for error in errors))
|
||||||
|
|
||||||
|
def test_delivery_operation_cannot_be_reused_as_a_profile_delivery_run(self) -> None:
|
||||||
|
board = routed_board()
|
||||||
|
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||||
|
board["tasks"][0]["status"] = "verified"
|
||||||
|
board["deliveryRuns"] = [
|
||||||
|
{
|
||||||
|
"id": "DR-duplicate-route",
|
||||||
|
"profile": "review",
|
||||||
|
"taskIds": ["DELIVERY-001"],
|
||||||
|
"status": "planned",
|
||||||
|
"sourceRevision": "a" * 40,
|
||||||
|
"configRevision": "b" * 40,
|
||||||
|
"pullRequest": None,
|
||||||
|
"artifacts": [],
|
||||||
|
"deployments": [],
|
||||||
|
"evidence": [],
|
||||||
|
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
errors = validate_tasks.validate_builtin(board)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any("deliveryRuns 不能引用 delivery-operation" in error for error in errors),
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_launcher_creates_an_operator_plan_on_the_low_cost_profile(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
project = Path(temporary).resolve()
|
||||||
|
executable = project / "codex"
|
||||||
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
executable.chmod(0o700)
|
||||||
|
routing = operator_orchestration()
|
||||||
|
routing["allowedWorktrees"] = [str(project)]
|
||||||
|
board = routed_board()
|
||||||
|
board["project"]["repoPath"] = str(project)
|
||||||
|
board["project"]["orchestration"] = routing
|
||||||
|
metadata = project.stat()
|
||||||
|
identity = {
|
||||||
|
"path": str(project),
|
||||||
|
"device": metadata.st_dev,
|
||||||
|
"inode": metadata.st_ino,
|
||||||
|
"gitCommonDir": str(project / ".git"),
|
||||||
|
"gitCommonDevice": metadata.st_dev,
|
||||||
|
"gitCommonInode": metadata.st_ino,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"load_authoritative_board",
|
||||||
|
return_value=(project, board),
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"capture_worktree_identity",
|
||||||
|
return_value=identity,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"resolve_executable",
|
||||||
|
return_value=executable,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"run_text",
|
||||||
|
return_value="codex-cli 1.0",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
plan = launch_worker.build_plan(
|
||||||
|
project_root_value=str(project),
|
||||||
|
task_id="DELIVERY-001",
|
||||||
|
attempt_id="DELIVERY-001-A1",
|
||||||
|
role="operator",
|
||||||
|
profile_id="codex-operator-standard",
|
||||||
|
worktree_value=str(project),
|
||||||
|
slot=1,
|
||||||
|
)
|
||||||
|
receipt = launch_worker.build_receipt(
|
||||||
|
"c" * 64,
|
||||||
|
plan,
|
||||||
|
"runtime-1",
|
||||||
|
{
|
||||||
|
"handle": "terminal-1",
|
||||||
|
"incarnationId": "incarnation-1",
|
||||||
|
"connected": True,
|
||||||
|
"writable": True,
|
||||||
|
"worktreePath": str(project),
|
||||||
|
},
|
||||||
|
"2026-08-01T10:00:00+08:00",
|
||||||
|
)
|
||||||
|
receipt_errors = worker_profiles.validate_worker_receipt(
|
||||||
|
receipt,
|
||||||
|
orchestration=routing,
|
||||||
|
task_ids={"DELIVERY-001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(plan["role"], "operator")
|
||||||
|
self.assertEqual(plan["requested"]["model"], "gpt-low")
|
||||||
|
self.assertEqual(
|
||||||
|
plan["requested"]["environmentPolicy"],
|
||||||
|
"per-cli-plus-operator-publish-v1",
|
||||||
|
)
|
||||||
|
self.assertTrue(plan["title"].startswith("ACK-OP-CODEX-STANDARD-"))
|
||||||
|
self.assertEqual(receipt_errors, [])
|
||||||
|
|
||||||
|
def test_launcher_binds_operator_to_delivery_operation_tasks(self) -> None:
|
||||||
|
delivery_board = routed_board()
|
||||||
|
ordinary_board = copy.deepcopy(delivery_board)
|
||||||
|
ordinary_task = ordinary_board["tasks"][0]
|
||||||
|
ordinary_task["id"] = "TASK-001"
|
||||||
|
ordinary_task["type"] = "feature"
|
||||||
|
del ordinary_task["operation"]
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"load_authoritative_board",
|
||||||
|
return_value=(Path("/repo/demo"), ordinary_board),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
launch_worker.LaunchError,
|
||||||
|
"operator 只能用于 delivery-operation",
|
||||||
|
):
|
||||||
|
launch_worker.build_plan(
|
||||||
|
project_root_value="/repo/demo",
|
||||||
|
task_id="TASK-001",
|
||||||
|
attempt_id="TASK-001-A1",
|
||||||
|
role="operator",
|
||||||
|
profile_id="codex-operator-standard",
|
||||||
|
worktree_value="/repo/demo",
|
||||||
|
slot=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"load_authoritative_board",
|
||||||
|
return_value=(Path("/repo/demo"), delivery_board),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
launch_worker.LaunchError,
|
||||||
|
"delivery-operation 任务只能由 operator",
|
||||||
|
):
|
||||||
|
launch_worker.build_plan(
|
||||||
|
project_root_value="/repo/demo",
|
||||||
|
task_id="DELIVERY-001",
|
||||||
|
attempt_id="DELIVERY-001-A1",
|
||||||
|
role="test",
|
||||||
|
profile_id="codex-test-standard",
|
||||||
|
worktree_value="/repo/demo",
|
||||||
|
slot=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_operator_gets_only_fixed_release_credentials(self) -> None:
|
||||||
|
with mock.patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "agent-token",
|
||||||
|
"DEB_TOKEN": "deb-token",
|
||||||
|
"DEB_SERVER_URL": "https://packages.example.com",
|
||||||
|
"DEB_REPOSITORY": "testing",
|
||||||
|
"SSH_AUTH_SOCK": "/tmp/agent.sock",
|
||||||
|
"GIT_SSH_COMMAND": "unsafe override",
|
||||||
|
"DOCKER_PASSWORD": "must-not-pass",
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
operator = launch_worker.worker_environment("codex", "operator")
|
||||||
|
test = launch_worker.worker_environment("codex", "test")
|
||||||
|
|
||||||
|
self.assertEqual(operator["DEB_TOKEN"], "deb-token")
|
||||||
|
self.assertEqual(operator["SSH_AUTH_SOCK"], "/tmp/agent.sock")
|
||||||
|
self.assertEqual(operator["OPENAI_API_KEY"], "agent-token")
|
||||||
|
self.assertNotIn("DEB_TOKEN", test)
|
||||||
|
self.assertNotIn("SSH_AUTH_SOCK", test)
|
||||||
|
self.assertNotIn("GIT_SSH_COMMAND", operator)
|
||||||
|
self.assertNotIn("DOCKER_PASSWORD", operator)
|
||||||
|
|
||||||
|
def test_ack_documents_the_three_routes_and_non_release_pr_boundary(self) -> None:
|
||||||
|
skill = (ACK_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
routing = (ACK_DIR / "references" / "delivery-routing.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
docker = (
|
||||||
|
REPO_ROOT / "skills" / "publish-docker-image" / "SKILL.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
release = (
|
||||||
|
REPO_ROOT / "skills" / "manage-release" / "SKILL.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("references/delivery-routing.md", skill)
|
||||||
|
for name in ("manage-release", "deb-publisher", "publish-docker-image"):
|
||||||
|
self.assertIn(name, routing)
|
||||||
|
self.assertIn("普通 PR/MR", routing)
|
||||||
|
self.assertIn("由显式调用的 `$ack`", docker)
|
||||||
|
self.assertIn("由显式调用的 `$ack`", release)
|
||||||
|
self.assertIn("PR-only", release)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
+12
-1
@@ -62,15 +62,26 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"templates/delivery.schema.json",
|
"templates/delivery.schema.json",
|
||||||
"examples/delivery.example.yaml",
|
"examples/delivery.example.yaml",
|
||||||
"references/delivery.md",
|
"references/delivery.md",
|
||||||
|
"references/delivery-routing.md",
|
||||||
):
|
):
|
||||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.11.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"
|
||||||
|
|||||||
Reference in New Issue
Block a user