2 Commits

Author SHA1 Message Date
laily a0f1c15b85 feat(ack): add one-off delivery routing 2026-08-01 15:29:34 +08:00
laily f8d03fad4d feat(ack): add Feishu bug intake 2026-08-01 14:37:34 +08:00
33 changed files with 2930 additions and 65 deletions
+89
View File
@@ -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 闭环执行。
+9
View File
@@ -0,0 +1,9 @@
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
version: 1
updatedAt: "2026-08-01T12:43:06+08:00"
project:
name: "skills"
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
verificationRegistry: {}
entries: []
+71
View File
@@ -0,0 +1,71 @@
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
> 本项目基于 ACK Skill v0.12.0。通用规范由 `/ack` 从 Skill 自身的
> `references/` 读取;本文件只保存当前仓库差异。
## 项目概览
- 项目:`skills`
- 技术栈:Python 3、Markdown、JSON Schema
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
- Base URL`n/a`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 覆盖层文件:`docs/ack/project.md`
## 通用规范(由 ACK Skill 按需读取)
- 开始需求:`references/kickoff.md`
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/orca-adapter.md`
## Worker 路由
机器可校验的 worker profile、允许 worktree 和 receipt 只以
`docs/ack/tasks.yaml` 为准。本次 Developer 使用当前 `record-bug` worktreeTest
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
## 路径权限
| 路径 | Coordinator | Test | Developer | 说明 |
|------|:-----------:|:----:|:---------:|------|
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
| `docs/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 |
| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 |
| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
| 其它 `tests/test_ack*.py` | Read-only | Read-only | Read-only | 既有 ACK 回归测试 |
| 临时测试目录 | Read-only | R/W | R/W | 必须由 `tempfile`/`mktemp -d` 创建,不保存凭据 |
| `~/.lark-cli/` | Read-only | Read-only | Read-only | 不读取、不修改真实飞书 profile;测试只能使用假 CLI |
## 命令
Developer 白盒验证:
```bash
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'
python3 -m unittest tests.test_ack_skill tests.test_ack_tasks_validation
python3 -m compileall -q skills/ack/scripts
```
Test 黑盒复测:
```bash
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
python3 -m unittest discover -s tests -p 'test_ack*.py'
python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 skills/ack/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
```
## 本次任务硬规则
- 只支持官方 `lark-cli`,所有读取命令必须显式携带项目配置的 `--profile`,不能依赖当前 active profile。
- 项目只保存 profile 名称、Base/table/view 标识和字段映射;App Secret、token 与用户凭据不得进入仓库、任务板或日志。
- 飞书接入第一版只读,不更新或删除表格记录,不切换 CLI active profile。
- 外部 CLI 缺失、profile 不存在、返回结构异常、附件下载失败时 fail closed,不回退到网页抓取。
- 只有 `ACK Ready` 视图中的记录可进入整理;每条记录保留稳定 `record_id` 来源引用,重复读取不得制造重复 ACK 来源。
- 不修改 `AGENTS.md``CLAUDE.md`,不提交或推送。
+533
View File
@@ -0,0 +1,533 @@
version: 1
updatedAt: "2026-08-01T13:34:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.12.0"
project:
name: "skills"
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
baseUrl: "n/a"
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
orchestration:
profileVersion: 1
mode: "orca"
allowedWorktrees:
- "/home/ace/orca/workspaces/.skills/record-bug"
modelAllowlist:
codex:
developer:
standard: ["gpt-5.6-terra"]
strong: ["gpt-5.6-sol"]
test:
standard: ["gpt-5.6-luna"]
profiles:
codex-dev-standard:
role: "developer"
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
codex-test-standard:
role: "test"
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
codex-dev-strong:
role: "developer"
cli: "codex"
tier: "strong"
model: "gpt-5.6-sol"
reasoningEffort: "high"
permissionMode: "workspace-write"
defaults:
developer: "codex-dev-standard"
test: "codex-test-standard"
developerUpgraded: "codex-dev-strong"
workerReceipts:
- receiptVersion: 1
id: "WR-1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
launchId: "1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:7de2f9ebf3065e7c044150fa87aed39582a8018b63a0dabac22194bf52ad52b7"
slot: 1
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A1"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_4d6278c9-83ac-4876-a29d-022adc346e6c"
incarnationId: "6daffb4c-e705-47f3-b7c2-d1ee1fb16c56"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T04:46:51.629061Z"
createdAt: "2026-08-01T04:46:42.869861Z"
receiptHash: "sha256:93e7e4454438a4d1c9a64eb7429563bb4b71348715cc4cb3e42c96cb9ca75fa4"
- receiptVersion: 1
id: "WR-035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
launchId: "035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:981fe3c23594573f564de9ab321e724324962958ed689ad3c0c98d7d165c1aa9"
slot: 2
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A1"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_ab858ba7-b972-45fb-bf50-61e96ced2830"
incarnationId: "9e20c158-7ac3-4a18-8dca-f9fec50126d6"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T04:57:33.909375Z"
createdAt: "2026-08-01T04:57:25.086609Z"
receiptHash: "sha256:1245532d66814f5d3b2a6a3b22293ead597f04390e628333569a7ba8763d0a84"
- receiptVersion: 1
id: "WR-3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
launchId: "3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:8264af6531e9828c0f994845b3a24931e22abea72d8a1d54a7c764577e544873"
slot: 3
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A2"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c"
incarnationId: "f77f05ff-fb2b-4099-ab03-070f61671673"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:01:26.824435Z"
createdAt: "2026-08-01T05:01:17.965038Z"
receiptHash: "sha256:c20c47c7491b5b399d2c29fce67a7035b432969bda0ccb74948eb03c3d085380"
- receiptVersion: 1
id: "WR-12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
launchId: "12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:621c7d5d6b7182d9a1ebf211fa11a80d7b100a3b682f8800f61031a651f5bf2a"
slot: 4
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A2"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_1ec9d2e1-a1b6-45db-8477-097e39a93ee4"
incarnationId: "a6f79ac3-9cc6-40a1-a38a-e7b7c2a49b02"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:05:36.189001Z"
createdAt: "2026-08-01T05:05:27.500696Z"
receiptHash: "sha256:824227bda0e78558807e10da9d89ec32ee4d7f37d782b866bca4629c5ac9e8fb"
- receiptVersion: 1
id: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
launchId: "90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:97ef498caf4a5f0d48fef22773c942a5065dfcf5cecefa22a6ad21df8d04bcd1"
slot: 5
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A3"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_88079d90-29a3-48ac-9b97-bec011abc249"
incarnationId: "5d4f785f-e0b5-4ab3-9de3-72ebc2da3b06"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:16:40.565911Z"
createdAt: "2026-08-01T05:16:31.788668Z"
receiptHash: "sha256:c62417b2ee8e9815228a18b2ee1be84b2844dada50eb9703a061e08e51e57eb7"
- receiptVersion: 1
id: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
launchId: "8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:6c7a45f7dd119bb6ef3bfaed7961c30f257285d940f248e0b16678dcad1bd19d"
slot: 6
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A3"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
incarnationId: "6e37ac75-5725-410c-87af-63f8bb42a911"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:25:08.564556Z"
createdAt: "2026-08-01T05:24:59.720042Z"
receiptHash: "sha256:dcbed5b2a3fa99312deba61951253992741eca885068e8f649613bd97eb3f59f"
summary:
verified: ["FEISHU-001"]
open: []
failedRetest: []
leftovers: []
tasks:
- id: "FEISHU-001"
type: "feature"
title: "ACK 从飞书多维表格按项目 profile 读取并整理 Bug"
priority: "P1"
status: "verified"
assignee: "test"
component: "skills/ack"
specRefs:
- "docs/ack-feishu-bug-intake.md"
testRefs:
- "tests/test_ack_feishu_intake_unit.py"
- "tests/test_ack_feishu_intake_e2e.py"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
扩展完整 ACK Skill,使项目可以在 tasks.yaml 中声明飞书多维表格 Bug 收件箱和
lark-cli profile。ACK 通过可信、无 shell 的读取器读取 ACK Ready 视图、按需
下载截图并输出可幂等整理的标准化记录;不同项目不会串用飞书租户。
stepsToReproduce:
- "创建两个假 lark-cli profiletenant-a 为 active,项目配置 tenant-b"
- "在 tenant-b 的 ACK Ready 视图返回一条带截图附件的 Bug 记录"
- "运行 ACK 飞书读取器 check/fetch,并重复提交同一标准化结果"
expected: >
读取器每次显式使用 tenant-b,仅读取配置 view,正确标准化文字与截图附件,
对所有错误 fail closedACK 按稳定 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
View File
@@ -13,6 +13,11 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
构建 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 步骤。
具体契约见 `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 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
@@ -173,7 +195,8 @@ argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git workt
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
写入任务的 `dispatch.developer``dispatch.test`,并同步写入本轮 `attemptId`
写入任务的 `dispatch.developer``dispatch.test``dispatch.operator`,并同步写入
本轮 `attemptId`
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
不能跨任务或跨轮次改挂。
@@ -223,5 +246,7 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
`project.deliveryFile``deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`
`project.deliveryFile``deliveryRuns`;从 `0.12.0` 起,新模板包含与 Test 同档模型
Operator,用于一次性交付路由。旧项目可以不迁移而继续使用原闭环,但补齐 Operator
前不能使用一次性交付。旧项目的 `kitVersion` 可以继续读取,但建议迁移为
`ackVersion`
+51 -8
View File
@@ -3,7 +3,8 @@ name: ack
description: >-
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
工作,或配置并执行任务验证后的项目交付流程时使用。
工作配置并执行任务验证后的项目交付流程,或把一次性 PR/发布请求路由给
manage-release、deb-publisher、publish-docker-image 普通 worker 时使用。
---
# ACK 项目协作入口
@@ -22,6 +23,8 @@ description: >-
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
- 用户直接要求创建发布 PR/MR、发布源码版本、DEB 或 Docker 镜像:执行
“一次性交付路由”。
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md``CLAUDE.md`
@@ -96,14 +99,49 @@ description: >-
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` 知识。
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test/operator` 的引用;receipt
必须绑定当前 ACK task、同一 role/profile/attempt`receiptId` 与 `attemptId`
必须同时为空或同时填写。
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 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`
知识,经确认后把固定 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` 并校验,
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
步骤放入同一份计划。确认前不派发实现,也不执行交付。
5. 创建或更换 worker 时,只使用
5. 创建或更换 Developer、Test 或 Operator worker 时,只使用
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
@@ -178,8 +221,8 @@ description: >-
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
提交、推送、创建终端、新 worktree、发布产物或部署。
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`Developer
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
维护中修改。
与 Test 只读,只能通过回报提名或验证知识;Operator 同样只读这些状态文件,只回传
一次性交付证据。`delivery.yaml` 只在显式的交付配置维护中修改。
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
+1 -1
View File
@@ -1 +1 @@
0.11.0
0.12.0
+2 -2
View File
@@ -1,6 +1,6 @@
interface:
display_name: "ACK"
short_description: "初始化、检查并运行 ACK 开发验证与可选交付闭环"
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile."
short_description: "运行 ACK 开发验证闭环,并路由 PR、源码、DEB 与镜像发布"
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:
allow_implicit_invocation: false
+1 -1
View File
@@ -1,6 +1,6 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.11.0。
> 本项目基于 ack v0.12.0。
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
+7 -1
View File
@@ -3,7 +3,7 @@
version: 1
updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.11.0"
ackVersion: "0.12.0"
project:
name: "notes-web"
repoPath: "/home/dev/notes-web"
@@ -104,6 +104,12 @@ tasks:
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
checkedBy: "test-worker-1"
checkedAt: "2026-07-06T09:36:00+08:00"
# 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。
source:
kind: "feishu-base"
ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
recordId: "recExample"
updatedAt: "2026-07-06T09:00:00Z"
description: >
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
stepsToReproduce:
+16 -1
View File
@@ -23,6 +23,8 @@
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
allowlist,默认 profile 与角色/档位一致。
- [ ] 需要一次性交付路由时存在 `defaults.operator`;其 CLI、standard 模型和
reasoning effort 与 `defaults.test` 完全相同。
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
## 路径权限
@@ -34,6 +36,7 @@
- [ ] `tasks.yaml` 只有 Coordinator 写。
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
- [ ] Operator 只回传一次性交付证据,不写 ACK 项目状态文件。
## 任务板
@@ -58,6 +61,16 @@
- [ ] 每次运行固定 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: []`
@@ -83,13 +96,15 @@
`orca terminal create --command` 或自由 worker 命令。
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
`--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 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
Orca live metadata 当作自动复用授权。
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
`dispatch.developer``dispatch.test`;每个引用的 task/role/profile/attempt
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
- [ ] 一次性交付 receipt 引用 `dispatch.operator`,与当前操作、profile 和 attempt
完全一致。
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml``knowledge.yaml`
- [ ] Test 使用的服务来自正确 worktree。
+108
View File
@@ -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;两种流程
必须保持独立,避免同一请求被重复发布。
+4
View File
@@ -121,3 +121,7 @@ ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
调用时,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 任务一律保留。
+5 -1
View File
@@ -82,6 +82,9 @@ docs/ack/
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
-`0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
自动补交付配置。
-`0.12.0` 起的新项目 orchestration 模板包含可选一次性交付所需的 Operator;其
默认 CLI、standard 模型和 reasoning effort 与 Test default 相同。旧项目不补也能
运行原闭环,但不能使用一次性交付路由。
- `updatedAt` 使用当前带时区时间。
- `project.name``repoPath``devWorktree``overlayFile``knowledgeFile` 使用
真实值。
@@ -141,7 +144,8 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
- Developer 与 Test 的验证命令可执行。
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
`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 只作审计,
v0.10 不自动复用旧终端。
- 网站或 API 项目写清服务启动、重启和 Base URL。
+25 -14
View File
@@ -1,6 +1,6 @@
# 模型路由(稳定核心)
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
本文件是**三角色与可选 Operator 的默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
工作,把机械执行交给较弱模型。
@@ -16,6 +16,7 @@
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
| Operator(一次性交付) | 与 Test 相同的中低模型 | 按低层发布 Skill 执行机械步骤并回传证据,不承担范围决策 |
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
@@ -36,6 +37,7 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
- Operator:执行 `delivery-routing.md` 已选定的发布 Skill,按其停止点记录证据。
---
@@ -73,7 +75,7 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
每个 profile 明确声明:
- `role``developer``test`
- `role``developer``test``operator`
- `cli`:受支持的 Agent CLI
- `tier`:角色模型档位;
- `model`:项目 allowlist 中的精确模型 ID
@@ -83,6 +85,9 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
必须命中 allowlistprofile 的模型也必须命中对应 CLI/角色/档位的精确列表。
`developer``test` 默认项是 Orca 模式必填;`operator` 对旧项目可选,但一次性
交付路由要求它存在。Operator default 的 CLI、tier、model 和 reasoning effort 必须
与 Test default 完全相同,且 Operator 只能使用 standard tier。
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
@@ -91,7 +96,8 @@ CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构
### 安全默认
- 能在完全只读工作树完成的角色优先选择 `read-only`
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
- Developer、发布 Operator 与需要在工作树生成构建/测试产物的 Test 使用
`workspace-write`;项目可
为纯观察型 Test 另建更严格的 `read-only` profile。
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
**未实现授权通道,统一 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 \
--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>]
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--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>] \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
@@ -148,11 +154,15 @@ v0.10 的 launcher 还执行以下约束:
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用
`per-cli-allowlist-v1`Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据,
不跨 CLI 透传。`DBUS_*``DISPLAY``WAYLAND_DISPLAY``XDG_RUNTIME_DIR`
`LD_*``PYTHON*``NODE_OPTIONS``CODEX_HOME``GIT_*` 和调用者 PATH 均不会
透传给 worker。
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;Developer/Test
worker 使用 `per-cli-allowlist-v1`Codex 只取得 Codex 所需凭据,Cursor 只取得
Cursor 凭据,不跨 CLI 透传。Operator 使用固定的
`per-cli-plus-operator-publish-v1`,在同样的 CLI allowlist 之外只允许
`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,还必须精确出现在
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
@@ -232,7 +242,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
改动文件清单。
改动文件清单和发布目标摘要
这样把昂贵推理挡在重复执行之外。
@@ -240,6 +250,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可
## 一句话
Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手
默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker
都必须经 launcher 产生可核对的 receipt。
Coordinator 是脑,Test 是眼,Developer 是手;一次性交付 Operator 是按已选 Skill
操作发布系统的手。Coordinator 用强模型做判断,其余角色默认用安全、较低成本的结构化
profile;只有常规闭环卡住时才升级,并且所有 worker 都必须经 launcher 产生可核对的
receipt。
+9 -3
View File
@@ -24,7 +24,8 @@ orca orchestration inbox --limit 20 --json
```
确认:Orca runtime 可达并取得本次 `runtimeId`Coordinator 终端存在;当前没有冲突
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
的活跃编排任务。Developer/Test worker 与一次性交付 Operator 都必须通过本次 launcher
新建,不要求预先存在。
---
@@ -59,7 +60,7 @@ dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--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>
```
@@ -69,7 +70,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py plan \
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--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> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
@@ -101,6 +102,11 @@ launcher fail closed,不能改用手写命令兜底。模型档位与升级规
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
既有会话可以由用户直接操作,但不能进入 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 又不破坏「验证者 ≠ 实现者」。
**一次性交付 Operator 不进入三角色开发闭环。** 用户显式调用 `$ack` 直接要求发布时,
Coordinator 按 `delivery-routing.md` 选择低层 Skill,并把原始请求交给独立 Operator。
Operator 与 Test 使用相同低成本模型/effort,但不承担独立复测;它只执行被选 Skill、
遵守其授权边界并回传发布证据,且不得写 ACK 状态文件。
---
## 三角色能力清单(SSOT
@@ -146,6 +151,11 @@ failed_retest(累计 3 轮) -> leftover
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
`type: delivery-operation` 使用独立的短状态流:`open -> dispatched -> verified | blocked`
这里的 `verified` 只表示 Coordinator 已按被路由 Skill 的完成标准核对一次性交付证据,
不表示代码经过 Test 复测,也不能作为普通功能任务的验证证据。远端部分成功不会自动
重派;恢复规则见 `delivery-routing.md`
## 交付状态(与任务状态正交)
任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在
+583
View File
@@ -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())
+45 -10
View File
@@ -41,6 +41,7 @@ from validate_tasks import load_document, validate_builtin # noqa: E402
from worker_profiles import ( # noqa: E402
LAUNCH_PROTOCOL_VERSION,
canonical_sha256,
environment_policy_for_role,
profile_hash,
render_worker_argv,
validate_routing_document,
@@ -48,7 +49,6 @@ from worker_profiles import ( # noqa: E402
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
RECEIPT_VERSION = 1
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
MAX_CONTROL_OUTPUT = 1024 * 1024
MAX_RECORD_SIZE = 256 * 1024
@@ -82,6 +82,15 @@ WORKER_CREDENTIAL_NAMES = {
"codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_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 = (
"LC_",
)
@@ -184,13 +193,23 @@ def control_environment() -> dict[str, str]:
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."""
credential_names = WORKER_CREDENTIAL_NAMES.get(cli)
if credential_names is None:
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:
@@ -633,15 +652,20 @@ def build_plan(
raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符")
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")
if role not in {"developer", "test"}:
raise LaunchError("role 必须是 developer 或 test")
if role not in {"developer", "test", "operator"}:
raise LaunchError("role 必须是 developer、test 或 operator")
if not PROFILE_ID_RE.fullmatch(profile_id):
raise LaunchError("profile-id 格式非法")
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99:
raise LaunchError("slot 必须是 1..99 的整数")
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"]
orchestration = project.get("orchestration")
if not isinstance(orchestration, dict):
@@ -681,7 +705,7 @@ def build_plan(
"cliVersion": cli_version,
"argv": argv,
"argvHash": canonical_sha256(argv),
"environmentPolicy": ENVIRONMENT_POLICY,
"environmentPolicy": environment_policy_for_role(role),
}
current_profile_hash = profile_hash(
profile,
@@ -705,7 +729,11 @@ def build_plan(
}
)
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]
title = (
f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-"
@@ -1453,7 +1481,10 @@ def bootstrap_worker(launch_id: str) -> int:
rebuilt["requested"]["argv"],
shell=False,
cwd=rebuilt["worktree"]["path"],
env=worker_environment(str(rebuilt["requested"]["cli"])),
env=worker_environment(
str(rebuilt["requested"]["cli"]),
str(rebuilt["role"]),
),
)
current_record.update(
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("--task-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("--worktree", required=True)
parser.add_argument("--slot", type=int, default=1)
+109 -1
View File
@@ -113,14 +113,30 @@ DELIVERY_STATUSES = {
}
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
FEISHU_REQUIRED_FIELDS = {
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
"attachments", "updatedAt",
}
FEISHU_CONFIG_FIELDS = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"}
FEISHU_SOURCE_FIELDS = {"kind", "ref", "recordId", "updatedAt"}
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
DISPATCH_FIELDS = {
"taskId",
"dispatchId",
"worker",
"developer",
"test",
"operator",
"rounds",
}
DELIVERY_OPERATION_FIELDS = {"skill", "request"}
DELIVERY_OPERATION_SKILLS = {
"manage-release",
"deb-publisher",
"publish-docker-image",
}
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
"kind",
"title",
@@ -423,11 +439,14 @@ def validate_delivery_runs(
value: object,
task_statuses: dict[str, object],
errors: list[str],
*,
delivery_operation_ids: set[str] | None = None,
) -> None:
if not isinstance(value, list):
errors.append("deliveryRuns 必须是列表")
return
excluded_task_ids = delivery_operation_ids or set()
seen_run_ids: set[str] = set()
for index, run in enumerate(value):
where = f"deliveryRuns[{index}]"
@@ -467,6 +486,11 @@ def validate_delivery_runs(
for task_id in task_ids:
if task_id not in task_statuses:
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
elif task_id in excluded_task_ids:
errors.append(
f"{where}.taskIds: deliveryRuns 不能引用 delivery-operation "
f"{task_id!r}"
)
elif task_statuses[task_id] != "verified":
errors.append(
f"{where}: delivery run 只能引用 verified 任务,"
@@ -654,6 +678,28 @@ def validate_builtin(data: dict) -> list[str]:
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
"project",
)
if "bugIntake" in project:
intake = project["bugIntake"]
if not isinstance(intake, dict):
errors.append("project.bugIntake 必须是对象")
else:
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
if intake.get("provider") != "feishu-base":
errors.append("project.bugIntake.provider 必须是 feishu-base")
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 (
"knowledgeFile" in project
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
@@ -710,6 +756,7 @@ def validate_builtin(data: dict) -> list[str]:
return errors
seen_ids: set[str] = set()
seen_source_refs: set[str] = set()
for i, task in enumerate(tasks):
where = f"tasks[{i}]"
if not isinstance(task, dict):
@@ -756,6 +803,49 @@ def validate_builtin(data: dict) -> list[str]:
)
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)
if "dispatch" not in task:
@@ -778,6 +868,12 @@ def validate_builtin(data: dict) -> list[str]:
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", [])
if not isinstance(rounds, list):
errors.append(f"{where}.dispatch.rounds: 必须是列表")
@@ -878,7 +974,19 @@ def validate_builtin(data: dict) -> list[str]:
for task in tasks
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
+71 -11
View File
@@ -23,13 +23,22 @@ RECEIPT_VERSION = 1
LAUNCH_PROTOCOL_VERSION = 1
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"})
TIERS = frozenset({"standard", "strong"})
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
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(
{
@@ -184,6 +193,16 @@ def _is_positive_int(value: Any) -> bool:
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:
if not isinstance(value, str):
return False
@@ -211,7 +230,7 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
permission = profile.get("permissionMode")
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:
errors.append(f"{where}.cli: must be codex/cursor-agent")
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:
errors.append(f"{where}.reasoningEffort: Cursor requires null")
if role == "test" and tier != "standard":
errors.append(f"{where}.tier: Test may only use standard")
if (
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":
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")
for tier in sorted(set(tiers) - TIERS, key=repr):
errors.append(f"{role_where}: unknown tier {tier!r}")
if role == "test" and "strong" in tiers:
errors.append(f"{role_where}: Test cannot define a strong allowlist")
if role in STANDARD_ONLY_ROLES and "strong" in tiers:
label = "Test" if role == "test" else "Operator"
errors.append(
f"{role_where}: {label} cannot define a strong allowlist"
)
for tier, models in tiers.items():
tier_where = f"{role_where}.{tier}"
if tier not in TIERS:
@@ -383,7 +410,7 @@ def validate_orchestration(
for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr):
errors.append(f"{where}.defaults: unknown key {default_key!r}")
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}")
for default_key, profile_id in defaults.items():
default_where = f"{where}.defaults.{default_key}"
@@ -407,6 +434,22 @@ def validate_orchestration(
if profile.get("permissionMode") not in PERMISSION_MODES:
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
@@ -502,7 +545,7 @@ def _validate_created_for(value: Any, where: str) -> list[str]:
errors.append(f"{where}.attemptId: must belong to taskId")
role = value.get("role")
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
@@ -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):
if argv_hash != canonical_sha256(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(
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
@@ -722,6 +766,19 @@ def validate_worker_receipt(
if binding.get("observedWorktreePath") != worktree.get("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 (
isinstance(created_for, dict)
and isinstance(worktree, dict)
@@ -977,7 +1034,10 @@ __all__ = [
"RECEIPT_VERSION",
"LAUNCH_PROTOCOL_VERSION",
"MAX_ROUNDS",
"BASE_ENVIRONMENT_POLICY",
"OPERATOR_ENVIRONMENT_POLICY",
"canonical_sha256",
"environment_policy_for_role",
"profile_hash",
"receipt_hash",
"render_worker_argv",
+4
View File
@@ -33,6 +33,7 @@
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令(可选):`references/orca-adapter.md`
- 验证后交付与配置维护(可选):`references/delivery.md`
- 一次性源码/DEB/镜像发布路由(可选):`references/delivery-routing.md`
## Worker 路由
@@ -46,6 +47,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|------|------------|------|
| Developer | `codex-dev-standard` | standard |
| Test | `codex-test-standard` | standard |
| Operator | `codex-operator-standard` | standard(模型/effort 与 Test 相同) |
| Developer 升级 | `codex-dev-strong` | strong |
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
@@ -102,6 +104,8 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
## 硬规则(其余见 references/
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
- 一次性交付 Operator 不进入三角色闭环;它使用 Test 同档模型执行被路由的低层 Skill,
只回传证据,不写 ACK 状态文件。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
禁止直接拼 `orca terminal create --command`,禁止 `command``extraArgs``env`
+97 -4
View File
@@ -42,6 +42,7 @@
"const": "docs/ack/delivery.yaml",
"description": "可选项目交付契约的唯一权威路径"
},
"bugIntake": { "$ref": "#/definitions/feishuBugIntake" },
"orchestration": {
"$ref": "#/definitions/orchestration"
}
@@ -164,6 +165,50 @@
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"feishuBugIntake": {
"type": "object",
"required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"],
"additionalProperties": false,
"properties": {
"provider": { "const": "feishu-base" },
"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": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,63}$"
@@ -204,7 +249,8 @@
"additionalProperties": false,
"properties": {
"developer": { "$ref": "#/definitions/modelTierAllowlist" },
"test": { "$ref": "#/definitions/modelTierAllowlist" }
"test": { "$ref": "#/definitions/modelTierAllowlist" },
"operator": { "$ref": "#/definitions/modelTierAllowlist" }
}
},
"modelAllowlist": {
@@ -229,7 +275,7 @@
"properties": {
"role": {
"type": "string",
"enum": ["developer", "test"]
"enum": ["developer", "test", "operator"]
},
"cli": {
"type": "string",
@@ -259,6 +305,7 @@
"properties": {
"developer": { "$ref": "#/definitions/profileId" },
"test": { "$ref": "#/definitions/profileId" },
"operator": { "$ref": "#/definitions/profileId" },
"developerUpgraded": { "$ref": "#/definitions/profileId" }
}
},
@@ -437,7 +484,10 @@
},
"environmentPolicy": {
"type": "string",
"const": "per-cli-allowlist-v1"
"enum": [
"per-cli-allowlist-v1",
"per-cli-plus-operator-publish-v1"
]
}
}
},
@@ -546,7 +596,7 @@
},
"role": {
"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": {
"type": "object",
"required": ["id", "title", "status"],
@@ -940,10 +1006,21 @@
"type": "array",
"items": { "$ref": "#/definitions/knowledgeCheck" }
},
"source": {
"if": {
"type": "object",
"required": ["kind"],
"properties": { "kind": { "const": "feishu-base" } }
},
"then": {
"$ref": "#/definitions/feishuTaskSource"
}
},
"description": { "type": "string" },
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
"expected": { "type": "string" },
"actual": { "type": "string" },
"operation": { "$ref": "#/definitions/deliveryOperation" },
"evidence": { "type": "object" },
"verification": { "type": "object" },
"dispatch": {
@@ -955,6 +1032,7 @@
"worker": { "type": ["string", "null"] },
"developer": { "$ref": "#/definitions/roleDispatch" },
"test": { "$ref": "#/definitions/roleDispatch" },
"operator": { "$ref": "#/definitions/roleDispatch" },
"rounds": {
"type": "array",
"items": { "$ref": "#/definitions/round" }
@@ -992,6 +1070,21 @@
},
"required": ["resolution"]
}
},
{
"if": {
"properties": { "type": { "const": "delivery-operation" } },
"required": ["type"]
},
"then": {
"required": ["operation", "dispatch"],
"properties": {
"dispatch": {
"type": "object",
"required": ["operator"]
}
}
}
}
]
}
+42
View File
@@ -11,6 +11,22 @@ project:
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
deliveryFile: "docs/ack/delivery.yaml"
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
# bugIntake:
# provider: "feishu-base"
# 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:
profileVersion: 1
mode: "orca"
@@ -23,11 +39,15 @@ project:
strong: ["gpt-5.6-sol"]
test:
standard: ["gpt-5.6-luna"]
operator:
standard: ["gpt-5.6-luna"]
cursor-agent:
developer:
standard: ["auto"]
test:
standard: ["auto"]
operator:
standard: ["auto"]
profiles:
codex-dev-standard:
role: "developer"
@@ -43,6 +63,13 @@ project:
model: "gpt-5.6-luna"
reasoningEffort: "low"
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:
role: "developer"
cli: "codex"
@@ -64,9 +91,17 @@ project:
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
cursor-operator-standard:
role: "operator"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
defaults:
developer: "codex-dev-standard"
test: "codex-test-standard"
operator: "codex-operator-standard"
developerUpgraded: "codex-dev-strong"
workerReceipts: []
@@ -95,6 +130,13 @@ tasks:
knowledgeCandidates: []
knowledgeChecks: []
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
# source:
# kind: "feishu-base"
# ref: "feishu-base:sha256:<64-lowercase-hex>"
# recordId: "<record-id>"
# updatedAt: "<source-updated-at>"
description: >
<What is wrong, in user-visible terms.>
+13 -2
View File
@@ -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
@@ -106,5 +116,6 @@ Agent 会先发现项目自己的版本、分支和发布规则,再检查本
## 不适用的场景
普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包
不需要触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布 PR/MR
或 release tag 时,才交给这个 Skill。
不需要直接触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布
PR/MR 或 release tag,或者用户显式调用 `$ack` 要求 PR-only 委派时,才交给这个
Skill。
+17
View File
@@ -8,6 +8,8 @@ description: >-
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
DEB/Docker 等产物、管理仓库权限时不使用。
由显式调用的 `$ack` 根据用户明确的普通 PR/MR 请求路由时,可只执行本文的
PR-only 流程,不把它扩大成版本发布。
---
# 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. 发现项目规则和当前状态
从项目根目录开始:
+2 -1
View File
@@ -2,7 +2,8 @@
name: publish-docker-image
description: >-
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
$publish-docker-image明确说“使用 publish-docker-image skill”时使用;
$publish-docker-image明确说“使用 publish-docker-image skill”,或由显式调用的 `$ack`
根据用户明确的 Docker/OCI 镜像发布请求路由时使用;
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
---
+353
View File
@@ -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()
+272
View File
@@ -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()
+232
View File
@@ -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
View File
@@ -62,15 +62,26 @@ class AckSkillContentTests(unittest.TestCase):
"templates/delivery.schema.json",
"examples/delivery.example.yaml",
"references/delivery.md",
"references/delivery-routing.md",
):
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
self.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(
'ackVersion: "<接入时的 ack skill 版本>"',
(ack_dir / "templates" / "tasks.template.yaml").read_text(
encoding="utf-8"
),
)
self.assertTrue((ack_dir / "references" / "feishu-bug-intake.md").is_file())
if __name__ == "__main__":
+33
View File
@@ -148,6 +148,39 @@ class AckTaskValidationTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
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:
current = valid_knowledge_board()
current["ackVersion"] = "0.10.0"