diff --git a/docs/ack-feishu-bug-intake.md b/docs/ack-feishu-bug-intake.md index 94a14dc..01a12fc 100644 --- a/docs/ack-feishu-bug-intake.md +++ b/docs/ack-feishu-bug-intake.md @@ -2,16 +2,16 @@ ## 目标 -让用户在飞书多维表格中跨设备记录文字和截图,随后由 ACK Coordinator 通过官方 -`lark-cli` 读取项目配置的 `ACK Ready` 视图,整理为独立、可验收、可幂等追踪的 ACK -任务。不同项目通过不同 CLI profile 访问各自飞书租户。 +让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并 +反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、 +可幂等追踪的 ACK 任务并启动三角色闭环。 ## 非目标 - 不用 Skill 承担人工记录入口。 - 不抓取公开网页或依赖浏览器登录态。 - 不把 App Secret、access token 或飞书用户凭据写入项目。 -- 第一版不反向更新飞书记录,不把飞书状态与 ACK 状态做双向同步。 +- 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书。 - 不把多条互不相关的 Bug 合成一次 Developer 派发。 ## 项目配置契约 @@ -22,6 +22,7 @@ | 字段 | 约束 | |------|------| | `provider` | 固定为 `feishu-base` | +| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 | | `profile` | `lark-cli` profile 名称;每次命令显式传入 | | `baseToken` | 飞书 Base token | | `tableId` | 数据表 ID | @@ -29,8 +30,8 @@ | `fields` | 逻辑字段到飞书字段 ID/名称的映射 | `fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、 -`acceptance`、`priority`、`attachments`、`updatedAt`。字段值只作为单个 argv 传给 -`lark-cli`,不经过 shell。 +`acceptance`、`priority`、`attachments`、`updatedAt`。启用审核前协作时还必须映射 +`fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。 ## CLI 契约 @@ -40,6 +41,10 @@ - profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。 - 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。 - 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。 +- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改 + 配置映射的 `fixLogic` 与 `acceptance`。 +- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务 + 校验器拒绝审核字段与该 hash 不一致的 reviewed task。 - 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。 - 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。 - 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与 @@ -53,8 +58,9 @@ - `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256 后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本; - `recordId` 与 `updatedAt`; -- title、actual、expected、steps、acceptance、priority; -- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、acceptance、priority; +- 绑定来源事实、附件元数据和审核字段的 `draftRevision`; +- title、actual、expected、steps、可选 fixLogic、acceptance、priority; +- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority; - 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用; - 原始字段中无法映射但不影响导入的警告。 @@ -63,18 +69,21 @@ token 或 CLI 配置文件内容。 ## Coordinator 整理规则 -1. 先运行读取器 `check`,确认 `lark-cli`、项目 profile 和所需只读能力可用。 +1. 先运行读取器 `check` 确认 `lark-cli` 和项目 profile,再单独核对 app 已获得所需读写 + scope;`check` 不把用户 token 状态当作 app/bot scope 证明。 2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift 整理动作;需要看截图时使用临时下载目录。 -3. 将记录分类为 Bug、已有功能、接受的改进、样式偏好或超范围;只导入确认接受的项。 -4. 每条导入任务保存 `source.kind=feishu-base`、`source.ref`、`source.recordId`、 - `source.updatedAt`,并把截图观察转成文字证据。 +3. 将记录分类并在飞书补全 `fixLogic` 与 `acceptance`;用户反馈时继续写回同一记录。 +4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改 + 应用代码。通过后每条导入任务保存 `source.kind=feishu-base`、`source.ref`、 + `source.recordId`、`source.updatedAt`、`source.workflow`、`source.approvedRevision`、 + `source.approvedPayloadHash`,并把截图观察转成文字证据。 5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。 6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。 7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。 8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed; - steps、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文补齐, - 并写入 `evidence.intakeEnrichment`,不要求报告者返回飞书机械补录。 + steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文 + 补齐并写回飞书。只有批准后的最终版本才导入任务板。 9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分 来源事实的残缺行不得静默跳过。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 09338a0..4b4dd72 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -132,11 +132,17 @@ description: >- worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` 知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文; 不全量注入知识库。 - 配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check, - 再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。 - `enrichmentRequired` 中的复现步骤、验收标准和优先级由 Coordinator 根据来源事实与 - 项目上下文补齐,并在任务证据中标记推断字段,不要求用户回飞书机械补录;标题、实际 - 表现和预期结果不可推断,部分缺失时仍停止。整行空白记录按批次 warning 跳过。 + `project.bugIntake.workflow` 为 `reviewed-writeback-v1` 时,按 + `references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan, + 读取用户填写的 Bug;Coordinator 根据来源事实与项目上下文补全修复逻辑和可观测验收 + 标准,只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。 + 用户针对当前 `draftRevision` 明确审核通过前,不创建或刷新 `tasks.yaml` 任务、不启动 + worker、不派发 Developer/Test,也不修改应用代码。审核通过后重新读取,要求 revision + 与批准值完全一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、 + `source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算 + payload hash 通过后才进入三角色闭环。未声明 workflow 的旧八字段配置只按 + `read-only-v1` 兼容,不得写回; + 标题、实际表现和预期结果不可推断;整行空白记录按批次 warning 跳过。 按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述; `dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index c317a91..a803cc2 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.13.1 +0.14.0 diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index f7c885e..cec888c 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.13.1。 +> 本项目基于 ack v0.14.0。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 3d937a6..7605a62 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -3,7 +3,7 @@ version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.13.1" +ackVersion: "0.14.0" project: name: "notes-web" repoPath: "/home/dev/notes-web" @@ -112,6 +112,12 @@ tasks: updatedAt: "2026-07-06T09:00:00Z" description: > 用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。 + fixLogic: > + 修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。 + acceptanceCriteria: + - "预览后确认区显示 title、修改前值、修改后值和 coverChanged" + - "取消预览不会产生保存请求" + - "原先确认区为空的问题不再出现" stepsToReproduce: - "打开 /fix?fileId=1" - "搜索并选择候选项" diff --git a/skills/ack/references/feishu-bug-intake.md b/skills/ack/references/feishu-bug-intake.md index 59629f1..e70ea04 100644 --- a/skills/ack/references/feishu-bug-intake.md +++ b/skills/ack/references/feishu-bug-intake.md @@ -1,24 +1,37 @@ -# 飞书 Base Bug 收件箱 +# 飞书 Base Bug 整理与审核 -这是可选的只读接入。项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明 -`provider: feishu-base`、显式 `profile`、`baseToken`、`tableId`、ACK Ready 的 `viewId` -和八个字段映射;不要保存 App Secret、access token 或任何 profile 凭据。 +飞书 Base 是 Bug 在**审核通过前的唯一协作区**。用户先在飞书记录 Bug 描述, +Coordinator 读取记录和项目现状,补全或修改修复逻辑、验收标准,再写回同一条飞书 +记录。用户可以继续在飞书反馈,Coordinator 按反馈反复修订。只有用户明确表示审核 +通过后,Coordinator 才把最终版本写入 `docs/ack/tasks.yaml`,随后启动 ACK 三角色闭环。 + +审核前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker,不得派发 +Developer/Test,也不得修改应用代码。飞书字段已填满、记录进入某个 view、用户暂时没有 +回复,都不等于审核通过。 + +## 项目配置 + +项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明 `provider: feishu-base`、 +`workflow: reviewed-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、用于候选 Bug +的 `viewId` 和字段映射;不要保存 App Secret、access token 或任何 profile 凭据。 + +原有八字段配置在未声明 `workflow` 时按 `read-only-v1` 兼容读取,不得写回或进入本节 +流程。启用审核前协作必须显式选择 `reviewed-writeback-v1`,并在 `title`、`actual`、 +`expected`、`stepsToReproduce`、`acceptance`、`priority`、`attachments`、`updatedAt` +之外配置可写文本字段 `fixLogic`。其中 `fixLogic` 和 `acceptance` 由 Coordinator 维护; +用户提供的标题、实际表现、预期结果和附件属于来源事实,Coordinator 不得覆盖。 ## 一次性安装与 profile 设置 在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm 包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin -映射,然后直接执行该官方包下载的 native binary;这样不依赖 nvm PATH,也不会让 Node -运行时变量进入凭据边界。缺少 native binary 或同名仿冒 wrapper 都会被拒绝: +映射,然后直接执行该官方包下载的 native binary: ```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、进程参数或项目文件: @@ -28,14 +41,13 @@ printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \ --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 的证明,因为它检查的是当前用户的 +项目配置只填写 `profile: project-feishu`。不要执行 `profile use`,也不要依赖 active +profile;读取、附件下载和写回都必须显式传 `--profile project-feishu`。profile 至少需要 +`base:record:read`、`base:record:write` 和 `docs:document.media:download`。 +不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的 stored user token。 -## 使用 +## 读取与整理计划 ```bash python3 /scripts/feishu_bug_intake.py check docs/ack/tasks.yaml @@ -46,48 +58,84 @@ python3 /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、分页、附件或路径任一异常都会失败且不输出伪成功结果。 +`fetch` 只调用官方 `base +record-list` 和附件下载命令,输出标准化 JSON,并为每条记录 +计算覆盖来源事实、附件元数据和审核字段的 `draftRevision`。`plan` 只生成 +`create` / `refresh` / `unchanged` / `drift` 候选动作,不修改飞书或任务板。**审核通过前 +不得执行这些候选动作**;它们只用于查重和预览最终导入结果。 -`plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为 -`create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为 -`unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复 -`sourceRef` 会直接失败。标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时 -读取失败;复现步骤、验收标准和优先级属于 Coordinator 可整理字段,缺失时记录与 action -会返回 `enrichmentRequired`,不阻断整批。没有附件且所有 Bug 内容字段都为空的误建行会 -跳过,并在批次 `warnings` 中返回 `blank_record_skipped`。 +标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时读取失败。复现步骤、 +修复逻辑、验收标准和优先级属于 Coordinator 可整理字段;缺失时记录与 action 返回 +`enrichmentRequired`。没有附件且所有 Bug 内容字段都为空的误建行会跳过,并在批次 +`warnings` 中返回 `blank_record_skipped`。 子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的 -`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时 -注入绕过项目 profile。每条记录最多 10 个附件、单批最多 100 个,单个附件最多 -20 MiB、合计最多 200 MiB,整批附件下载最多 5 分钟,并校验声明大小与落盘大小; -请始终使用新的临时目录作为 `--output-dir`。 +`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入。每条记录最多 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 整理 +对每条候选 Bug,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 任务一律保留。 +1. 读取用户填写的来源事实、附件和项目代码/测试,确认问题边界;证据不足时把不确定点 + 明确写成假设,不伪装成用户原文。 +2. 整理复现步骤和优先级,并生成: + - `fixLogic`:说明根因判断、计划修改的位置与行为、需要保持的不变量,以及回归风险; + 它是待审核的实现方向,不宣称代码已经修改。 + - `acceptance`:写成可观测、可独立复测的标准,至少覆盖用户可见结果、真实状态或 API + 结果,以及原失败不再出现;不扩张用户没有表达的产品范围。 +3. 把本轮草案保存为不超过 64 KiB 的临时 JSON:只包含非空字符串 `fixLogic` 和非空 + 字符串数组 `acceptance`。通过安全适配器写回当前飞书记录: -当 action 带 `enrichmentRequired` 时,由 Coordinator 补齐后再写入任务板,不要求报告者 -返回飞书机械补字段: + ```bash + python3 /scripts/feishu_bug_intake.py write-draft \ + docs/ack/tasks.yaml --record-id \ + --expected-source-ref \ + --expected-draft-revision --input + ``` -- `steps`:根据标题、实际表现、附件和项目现状整理 2 至 5 个可复现步骤;证据不足时明确 - 写成待 Developer 首轮确认的复现假设,不能把假设伪装成来源原文。 -- `acceptance`:把预期结果改写成可观测信号,至少覆盖用户可见结果、真实状态或 API 结果, - 以及原失败不再出现;不扩张飞书记录没有表达的产品范围。 -- `priority`:主流程阻断、数据损坏/丢失或安全风险定为 P0;普通功能错误默认 P1;纯样式或 - 低频体验问题可定为 P2。无法判断时使用 P1,并保留判断依据。 + `write-draft` 会在写前确认 record 属于配置 view,且 `sourceRef`、`draftRevision` 与调用者 + 看到的版本一致;它只允许覆盖配置映射的 `fixLogic` 和 `acceptance`,拒绝 symlink、超限 + 或多余字段输入,并复用读取器的可信 CLI、显式 profile、最小环境和成功 envelope 校验。 + 不得绕过适配器直接调用 PATH 中的 CLI,也不得改标题、实际表现、预期结果、附件或其它 + 用户字段。 +4. `write-draft` 会回读同一记录、确认落盘值一致并返回当前 `draftRevision`。把这个 revision + 连同记录交给用户审核;不能只展示没有 revision 的自由文本。 +5. 用户提出意见时,重新读取最新记录和反馈,修订后再次写回、回读、等待审核;不要在 + 本地或聊天中维护一份与飞书分叉的“最终草案”。 -在任务 `evidence.intakeEnrichment` 中记录哪些字段由 Coordinator 推断及依据。Developer -必须先复现或用失败测试确认推断步骤;Test 仍按任务中的可观测验收信号独立复测。标题、 -实际表现和预期结果不得由 Coordinator 补造,部分缺失时继续 fail closed。 +任一写回、回读或字段校验失败时停止本轮并报告,不能转入任务板或三角色处理。 + +## 审核门禁与导入 + +只有用户针对当前飞书记录的明确 `draftRevision` 表示“审核通过”“可以执行”或等价授权, +才解除门禁。授权必须发生在最后一次草案写回和回读之后;之后若用户字段、附件、 +`fixLogic` 或 `acceptance` 再次变化,revision 会变化,原授权失效,必须重新审核。 + +审核通过后,Coordinator 才执行以下动作: + +1. 通过适配器重新读取并生成规范任务草案;命令会要求当前 `sourceRef` 和 + `draftRevision` 精确等于用户批准值: + + ```bash + python3 /scripts/feishu_bug_intake.py import-approved \ + docs/ack/tasks.yaml --record-id \ + --expected-source-ref \ + --expected-draft-revision + ``` + + 不一致时停止并重新审核。输出的 `taskDraft` 包含批准后的规范字段以及 + `source.workflow`、`source.approvedRevision`、`source.approvedPayloadHash`。 +2. 按 `source.ref` 查重,把 `taskDraft` 原样合并到新的 ACK task,只补任务 ID、状态、 + dispatch 等 ACK 运行字段;不得手工改写 `fixLogic`、`acceptanceCriteria` 或其它审核字段。 + 可在 `evidence.intakeEnrichment` 标明 Coordinator 推断依据,但它不替代规范字段。 +3. 运行 `validate_tasks.py`。校验器会重算 `approvedPayloadHash`;只有写入和校验都成功, + 任务才可作为已确认的 `open` 任务 + 进入 ACK 闭环。 +4. 按 Coordinator → Developer → Test 的标准流程派发、实现和独立复测。 + +相同来源只更新尚未派发的 `open` 任务;`dispatched`、`fixed_by_dev`、`retesting`、 +`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,由用户决定是否 +新建任务。项目启用 reviewed workflow 后,历史 `verified` / `leftover` 只读来源继续保留; +其它仍可执行的 legacy 飞书任务必须先按当前流程重新整理和审核,不能通过省略或降级 +`source.workflow` 绕过门禁。飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。 diff --git a/skills/ack/scripts/approval_payload.py b/skills/ack/scripts/approval_payload.py new file mode 100644 index 0000000..47ceade --- /dev/null +++ b/skills/ack/scripts/approval_payload.py @@ -0,0 +1,50 @@ +"""Canonical reviewed Bug task payload shared by intake and validation.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any + + +PAYLOAD_FIELDS = ( + "title", + "description", + "priority", + "actual", + "expected", + "stepsToReproduce", + "fixLogic", + "acceptanceCriteria", +) +NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+") + + +def review_items(value: str) -> list[str]: + """Recover line or numbered-list review text as stable non-empty items.""" + lines = [line.strip(" \t-*•") for line in value.splitlines() if line.strip()] + if len(lines) > 1: + return lines + text = value.strip() + matches = list(NUMBERED_ITEM.finditer(text)) + if matches: + items: list[str] = [] + for index, match in enumerate(matches): + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + item = text[start:end].strip() + if item: + items.append(item) + if items: + return items + return [text] if text else [] + + +def approval_payload_hash(task: dict[str, Any]) -> str: + """Hash the exact reviewed fields that Developer and Test consume.""" + payload = {field: task.get(field) for field in PAYLOAD_FIELDS} + encoded = json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + ).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" diff --git a/skills/ack/scripts/feishu_bug_intake.py b/skills/ack/scripts/feishu_bug_intake.py index 02c7f16..2cd93a0 100644 --- a/skills/ack/scripts/feishu_bug_intake.py +++ b/skills/ack/scripts/feishu_bug_intake.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Read an ACK-ready Feishu Base view through the official lark-cli. +"""Read and review 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. +The only mutation is a bounded draft write to configured Coordinator fields. +The adapter never reads the active profile and emits one JSON document only +on success. """ from __future__ import annotations @@ -23,12 +24,14 @@ import time from pathlib import Path from typing import Any +from approval_payload import approval_payload_hash, review_items from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt") +OPTIONAL_FIELDS = ("fixLogic",) SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt") COORDINATOR_FIELDS = ("steps", "acceptance", "priority") -BUG_CONTENT_FIELDS = ("title", "actual", "expected", *COORDINATOR_FIELDS) +BUG_CONTENT_FIELDS = ("title", "actual", "expected", "fixLogic", *COORDINATOR_FIELDS) MAX_PAGES = 100 MAX_RECORDS = 10_000 PAGE_SIZE = 100 @@ -39,10 +42,13 @@ MAX_TOTAL_ATTACHMENTS = 100 MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024 MAX_ATTACHMENT_BATCH_SECONDS = 300 +MAX_DRAFT_BYTES = 64 * 1024 SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$") PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") +DRAFT_REVISION = re.compile(r"^sha256:[0-9a-f]{64}$") +WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"} class IntakeError(Exception): @@ -182,6 +188,60 @@ def load_board(path: Path) -> dict[str, Any]: return value +def load_draft(path: Path) -> dict[str, Any]: + """Load one bounded, regular JSON file with the two writable draft fields.""" + descriptor: int | None = None + try: + before = path.lstat() + if not stat.S_ISREG(before.st_mode) or path.is_symlink(): + raise IntakeError("draft input must be a regular file") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or (before.st_dev, before.st_ino) != (metadata.st_dev, metadata.st_ino) + ): + raise IntakeError("draft input changed while opening") + if metadata.st_size <= 0 or metadata.st_size > MAX_DRAFT_BYTES: + raise IntakeError("draft input size is invalid") + chunks: list[bytes] = [] + total = 0 + while total <= MAX_DRAFT_BYTES: + chunk = os.read(descriptor, min(64 * 1024, MAX_DRAFT_BYTES + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + content = b"".join(chunks) + if len(content) != metadata.st_size: + raise IntakeError("draft input changed while reading") + value = load_json_unique(content.decode("utf-8")) + except IntakeError: + raise + except (OSError, UnicodeError, json.JSONDecodeError, DuplicateKeyError) as exc: + raise IntakeError("cannot read draft input") from exc + finally: + if descriptor is not None: + os.close(descriptor) + if not isinstance(value, dict) or set(value) != {"fixLogic", "acceptance"}: + raise IntakeError("draft input must contain exactly fixLogic and acceptance") + fix_logic = value["fixLogic"] + acceptance = value["acceptance"] + if not isinstance(fix_logic, str) or not fix_logic.strip(): + raise IntakeError("draft fixLogic must be a non-empty string") + if ( + not isinstance(acceptance, list) + or not acceptance + or any(not isinstance(item, str) or not item.strip() for item in acceptance) + ): + raise IntakeError("draft acceptance must be a non-empty string list") + return { + "fixLogic": fix_logic.strip(), + "acceptance": [item.strip() for item in acceptance], + } + + def config_from_board(board: dict[str, Any]) -> dict[str, Any]: project = board.get("project") if not isinstance(project, dict) or "bugIntake" not in project: @@ -189,12 +249,15 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]: config = project["bugIntake"] if not isinstance(config, dict): raise IntakeError("project.bugIntake must be an object") - allowed = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"} + allowed = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"} unknown = sorted(set(config) - allowed) if unknown: raise IntakeError("bugIntake has unknown fields") if config.get("provider") != "feishu-base": raise IntakeError("bugIntake.provider must be feishu-base") + workflow = config.get("workflow", "read-only-v1") + if workflow not in WORKFLOWS: + raise IntakeError("bugIntake.workflow is invalid") profile = config.get("profile") if not isinstance(profile, str) or not PROFILE.fullmatch(profile): raise IntakeError("bugIntake.profile is invalid") @@ -203,12 +266,19 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]: 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") + supported = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS) + if ( + not isinstance(fields, dict) + or not set(REQUIRED_FIELDS).issubset(fields) + or not set(fields).issubset(supported) + ): + raise IntakeError("bugIntake.fields must map all required and only supported logical fields") if any(not isinstance(value, str) or not SAFE_VALUE.fullmatch(value) for value in fields.values()): raise IntakeError("bugIntake.fields values are invalid") if len(set(fields.values())) != len(fields): raise IntakeError("bugIntake.fields values must be unique") + if workflow == "reviewed-writeback-v1" and "fixLogic" not in fields: + raise IntakeError("reviewed writeback requires bugIntake.fields.fixLogic") return config @@ -381,7 +451,11 @@ def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tupl def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]: - field_ids = [config["fields"][logical] for logical in REQUIRED_FIELDS] + logical_fields = [ + logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS) + if logical in config["fields"] + ] + field_ids = [config["fields"][logical] for logical in logical_fields] all_rows: list[tuple[str, list[Any]]] = [] offset = 0 for _ in range(MAX_PAGES): @@ -444,6 +518,37 @@ def source_ref(config: dict[str, Any], record_id: str) -> str: return f"feishu-base:sha256:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}" +def draft_revision( + record: dict[str, Any], attachment_tokens: list[str] | None = None, +) -> str: + """Bind approval to the normalized source facts and review-controlled fields.""" + tokens = attachment_tokens or [] + if len(tokens) != len(record["attachments"]): + raise IntakeError("draft revision attachment identity is incomplete") + stable = { + "sourceRef": record["sourceRef"], + "updatedAt": record["updatedAt"], + "title": record["title"], + "actual": record["actual"], + "expected": record["expected"], + "steps": record["steps"], + "fixLogic": record["fixLogic"], + "acceptance": record["acceptance"], + "priority": record["priority"], + "attachments": [ + { + **{key: attachment.get(key) for key in ("name", "type", "size")}, + "tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}", + } + for attachment, token in zip(record["attachments"], tokens) + ], + } + encoded = json.dumps( + stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + ).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: profile_check(config) prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = [] @@ -451,7 +556,11 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: total_attachments = 0 total_attachment_bytes = 0 for record_id, row in fetch_pages(config): - cells = dict(zip(REQUIRED_FIELDS, row)) + logical_fields = [ + logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS) + if logical in config["fields"] + ] + cells = dict(zip(logical_fields, row)) attachment_data = attachment_items(cells["attachments"]) total_attachments += len(attachment_data) total_attachment_bytes += sum(metadata["size"] for metadata, _ in attachment_data) @@ -459,16 +568,22 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: 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": []} + record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []} if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS): batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"}) continue for field in SOURCE_FACT_FIELDS: if not record[field]: raise IntakeError(f"record {field} must not be empty") + enrichment_fields = list(COORDINATOR_FIELDS) + if "fixLogic" in config["fields"]: + enrichment_fields.append("fixLogic") record["enrichmentRequired"] = [ - field for field in COORDINATOR_FIELDS if not record[field] + field for field in enrichment_fields if not record[field] ] + record["draftRevision"] = draft_revision( + record, [token for _, token in attachment_data], + ) prepared.append((record, attachment_data)) download_root: Path | None = None if output_dir is not None: @@ -493,7 +608,117 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: attachment["size"], min(60, remaining), ) records.append(record) - return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings} + return {"provider": "feishu-base", "workflow": config.get("workflow", "read-only-v1"), "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings} + + +def review_record( + config: dict[str, Any], record_id: str, expected_source_ref: str, + expected_revision: str, +) -> dict[str, Any]: + """Resolve one record inside the configured view and bind its reviewed version.""" + if RECORD_ID.fullmatch(record_id) is None: + raise IntakeError("review record id is invalid") + if SOURCE_REF.fullmatch(expected_source_ref) is None: + raise IntakeError("expected source reference is invalid") + if DRAFT_REVISION.fullmatch(expected_revision) is None: + raise IntakeError("expected draft revision is invalid") + matching = [ + record for record in fetch(config, None)["records"] + if record["recordId"] == record_id + ] + if len(matching) != 1: + raise IntakeError("configured review view did not contain exactly one record") + if ( + matching[0]["sourceRef"] != expected_source_ref + or matching[0]["draftRevision"] != expected_revision + ): + raise IntakeError("review record changed before the requested operation") + return matching[0] + + +def write_draft( + config: dict[str, Any], record_id: str, expected_source_ref: str, + expected_revision: str, draft_path: Path, +) -> dict[str, Any]: + """Overwrite only the configured fix logic and acceptance cells.""" + if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1": + raise IntakeError("draft writeback requires reviewed-writeback-v1 workflow") + if "fixLogic" not in config["fields"]: + raise IntakeError("bugIntake.fields.fixLogic is required for draft writeback") + review_record(config, record_id, expected_source_ref, expected_revision) + draft = load_draft(draft_path) + patch = { + config["fields"]["fixLogic"]: draft["fixLogic"], + config["fields"]["acceptance"]: "\n".join( + f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1) + ), + } + profile_check(config) + run_cli([ + "base", "+record-upsert", "--profile", config["profile"], + "--base-token", config["baseToken"], "--table-id", config["tableId"], + "--record-id", record_id, "--json", + json.dumps(patch, ensure_ascii=False, separators=(",", ":")), + "--format", "json", + ]) + matching = [record for record in fetch(config, None)["records"] if record["recordId"] == record_id] + if len(matching) != 1 or matching[0]["sourceRef"] != expected_source_ref: + raise IntakeError("draft writeback readback did not find exactly one record") + expected_acceptance = text(patch[config["fields"]["acceptance"]]) + if ( + matching[0]["fixLogic"] != text(draft["fixLogic"]) + or matching[0]["acceptance"] != expected_acceptance + ): + raise IntakeError("draft writeback readback did not match the submitted draft") + return { + "provider": "feishu-base", + "recordId": record_id, + "written": ["fixLogic", "acceptance"], + "draftRevision": matching[0]["draftRevision"], + "ok": True, + } + + +def import_approved( + config: dict[str, Any], record_id: str, expected_source_ref: str, + expected_revision: str, +) -> dict[str, Any]: + """Emit the canonical task payload for one explicitly approved draft revision.""" + if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1": + raise IntakeError("approved import requires reviewed-writeback-v1 workflow") + record = review_record( + config, record_id, expected_source_ref, expected_revision, + ) + steps = review_items(record["steps"]) + acceptance = review_items(record["acceptance"]) + if not record["priority"] or not steps or not record["fixLogic"] or not acceptance: + raise IntakeError("approved record is missing prepared review fields") + task_draft: dict[str, Any] = { + "title": record["title"], + "priority": record["priority"], + "description": record["title"], + "actual": record["actual"], + "expected": record["expected"], + "stepsToReproduce": steps, + "fixLogic": record["fixLogic"], + "acceptanceCriteria": acceptance, + "source": { + "kind": "feishu-base", + "workflow": "reviewed-writeback-v1", + "ref": record["sourceRef"], + "recordId": record["recordId"], + "updatedAt": record["updatedAt"], + "approvedRevision": record["draftRevision"], + }, + } + task_draft["source"]["approvedPayloadHash"] = approval_payload_hash(task_draft) + return { + "provider": "feishu-base", + "recordId": record_id, + "draftRevision": record["draftRevision"], + "taskDraft": task_draft, + "ok": True, + } def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -529,10 +754,12 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d ref = record.get("sourceRef") record_id = record.get("recordId") updated_at = record.get("updatedAt") + revision = record.get("draftRevision") if ( not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None or not isinstance(record_id, str) or RECORD_ID.fullmatch(record_id) is None or not isinstance(updated_at, str) or not updated_at + or not isinstance(revision, str) or DRAFT_REVISION.fullmatch(revision) is None ): raise IntakeError("normalized Feishu record identity is invalid") if ref in seen_records: @@ -540,14 +767,22 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d seen_records.add(ref) task = existing.get(ref) if task is None: - planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "action": "create"} + planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "draftRevision": record["draftRevision"], "action": "create"} enrichment_required = record.get("enrichmentRequired") if enrichment_required: planned_action["enrichmentRequired"] = enrichment_required actions.append(planned_action) continue source = task["source"] - if source["updatedAt"] == updated_at: + approved_revision = source.get("approvedRevision") + if isinstance(approved_revision, str): + if approved_revision == revision: + action_name = "unchanged" + elif task["status"] == "open": + action_name = "refresh" + else: + action_name = "drift" + elif source["updatedAt"] == updated_at: action_name = "unchanged" elif task["status"] == "open": action_name = "refresh" @@ -556,6 +791,7 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d planned_action = { "sourceRef": ref, "recordId": record_id, + "draftRevision": record["draftRevision"], "taskId": task["id"], "status": task["status"], "action": action_name, @@ -568,25 +804,41 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Read a configured Feishu Base bug intake") + parser = argparse.ArgumentParser(description="Read and review a configured Feishu Base bug intake") sub = parser.add_subparsers(dest="command", required=True) - for name in ("check", "fetch", "plan"): + for name in ("check", "fetch", "plan", "write-draft", "import-approved"): command = sub.add_parser(name) command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board") if name in {"fetch", "plan"}: command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments") + if name in {"write-draft", "import-approved"}: + command.add_argument("--record-id", required=True, help="existing Feishu Base record id") + command.add_argument("--expected-source-ref", required=True, help="sourceRef returned by fetch") + command.add_argument("--expected-draft-revision", required=True, help="draftRevision returned by fetch") + if name == "write-draft": + command.add_argument("--input", type=Path, required=True, help="bounded JSON draft file") args = parser.parse_args(argv) try: board = load_board(args.tasks) config = config_from_board(board) if args.command == "check": profile_check(config) - output = {"provider": "feishu-base", "profile": config["profile"], "ok": True} + output = {"provider": "feishu-base", "workflow": config.get("workflow", "read-only-v1"), "profile": config["profile"], "ok": True} elif args.command == "fetch": output = fetch(config, args.output_dir) - else: + elif args.command == "plan": output = fetch(config, args.output_dir) output["actions"] = plan_actions(board, output["records"]) + elif args.command == "write-draft": + output = write_draft( + config, args.record_id, args.expected_source_ref, + args.expected_draft_revision, args.input, + ) + else: + output = import_approved( + config, args.record_id, args.expected_source_ref, + args.expected_draft_revision, + ) except IntakeError as exc: sys.stderr.write(f"Feishu bug intake failed: {exc}\n") return 1 diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index 5e6bc1c..38d2a7e 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -24,6 +24,7 @@ import re import sys from pathlib import Path +from approval_payload import approval_payload_hash from yaml_subset import ( DuplicateKeyError, YamlSubsetError, @@ -117,8 +118,13 @@ 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_OPTIONAL_FIELDS = {"fixLogic"} +FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"} +FEISHU_SOURCE_FIELDS = { + "kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision", + "approvedPayloadHash", +} +FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"} FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$") FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") @@ -598,6 +604,7 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]: def validate_builtin(data: dict) -> list[str]: errors: list[str] = [] + project_intake_workflow = "read-only-v1" def validate_string_fields( value: dict, @@ -671,6 +678,11 @@ def validate_builtin(data: dict) -> list[str]: reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors) if intake.get("provider") != "feishu-base": errors.append("project.bugIntake.provider 必须是 feishu-base") + workflow = intake.get("workflow", "read-only-v1") + if workflow in FEISHU_WORKFLOWS: + project_intake_workflow = workflow + if workflow not in FEISHU_WORKFLOWS: + errors.append("project.bugIntake.workflow 非法") profile = intake.get("profile") if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None: errors.append("project.bugIntake.profile 非法") @@ -679,12 +691,18 @@ def validate_builtin(data: dict) -> list[str]: 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: + if ( + not isinstance(fields, dict) + or not FEISHU_REQUIRED_FIELDS.issubset(fields) + or not set(fields).issubset(FEISHU_REQUIRED_FIELDS | FEISHU_OPTIONAL_FIELDS) + ): errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段") elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()): errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串") elif len(set(fields.values())) != len(fields): errors.append("project.bugIntake.fields 字段值不能重复") + elif workflow == "reviewed-writeback-v1" and "fixLogic" not in fields: + errors.append("reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic") if ( "knowledgeFile" in project and project.get("knowledgeFile") != "docs/ack/knowledge.yaml" @@ -776,6 +794,7 @@ def validate_builtin(data: dict) -> list[str]: "assignee", "component", "description", + "fixLogic", "expected", "actual", }, @@ -783,7 +802,7 @@ def validate_builtin(data: dict) -> list[str]: ) validate_string_lists( task, - {"specRefs", "testRefs", "stepsToReproduce"}, + {"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"}, where, ) validate_object_fields(task, {"evidence", "verification"}, where) @@ -806,6 +825,52 @@ def validate_builtin(data: dict) -> list[str]: errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID") if not _nonempty_string(source.get("updatedAt")): errors.append(f"{where}.source.updatedAt: 必须是非空字符串") + source_workflow = source.get("workflow", "read-only-v1") + if source_workflow not in FEISHU_WORKFLOWS: + errors.append(f"{where}.source.workflow: 非法") + if ( + project_intake_workflow == "reviewed-writeback-v1" + and source_workflow != "reviewed-writeback-v1" + and status not in {"verified", "leftover"} + ): + errors.append( + f"{where}.source.workflow: reviewed 项目的可执行飞书任务必须先迁移审核" + ) + approved_revision = source.get("approvedRevision") + stored_payload_hash = source.get("approvedPayloadHash") + if source_workflow == "reviewed-writeback-v1" and approved_revision is None: + errors.append(f"{where}.source.approvedRevision: reviewed workflow 必填") + elif approved_revision is not None and ( + not isinstance(approved_revision, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None + ): + errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision") + if source_workflow == "reviewed-writeback-v1": + if ( + not isinstance(stored_payload_hash, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None + ): + errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填") + required_strings = ("title", "priority", "actual", "expected", "fixLogic") + for field in required_strings: + if not _nonempty_string(task.get(field)): + errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串") + for field in ("stepsToReproduce", "acceptanceCriteria"): + items = task.get(field) + if ( + not isinstance(items, list) + or not items + or any(not _nonempty_string(item) for item in items) + ): + errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串列表") + if ( + isinstance(stored_payload_hash, str) + and re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) + and stored_payload_hash != approval_payload_hash(task) + ): + errors.append(f"{where}.source.approvedPayloadHash: 与任务审核字段不匹配") + elif stored_payload_hash is not None: + errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow") validate_knowledge_fields(task, where, status, errors) diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 3c5a225..e5165cb 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -171,6 +171,10 @@ "additionalProperties": false, "properties": { "provider": { "const": "feishu-base" }, + "workflow": { + "type": "string", + "enum": ["read-only-v1", "reviewed-writeback-v1"] + }, "profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, "baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, @@ -184,13 +188,27 @@ "actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, + "fixLogic": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }, "updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" } } } - } + }, + "allOf": [ + { + "if": { + "properties": { "workflow": { "const": "reviewed-writeback-v1" } }, + "required": ["workflow"] + }, + "then": { + "properties": { + "fields": { "required": ["fixLogic"] } + } + } + } + ] }, "feishuTaskSource": { "type": "object", @@ -198,6 +216,10 @@ "additionalProperties": false, "properties": { "kind": { "const": "feishu-base" }, + "workflow": { + "type": "string", + "enum": ["read-only-v1", "reviewed-writeback-v1"] + }, "ref": { "type": "string", "pattern": "^feishu-base:sha256:[0-9a-f]{64}$" @@ -206,8 +228,21 @@ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$" }, - "updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" } - } + "updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "approvedRevision": { "$ref": "#/definitions/sha256" }, + "approvedPayloadHash": { "$ref": "#/definitions/sha256" } + }, + "allOf": [ + { + "if": { + "properties": { "workflow": { "const": "reviewed-writeback-v1" } }, + "required": ["workflow"] + }, + "then": { + "required": ["approvedRevision", "approvedPayloadHash"] + } + } + ] }, "profileId": { "type": "string", @@ -996,6 +1031,12 @@ } }, "description": { "type": "string" }, + "fixLogic": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "acceptanceCriteria": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1, "pattern": "\\S" } + }, "stepsToReproduce": { "type": "array", "items": { "type": "string" } }, "expected": { "type": "string" }, "actual": { "type": "string" }, diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index dfb097d..6695809 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -14,6 +14,7 @@ project: # 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。 # bugIntake: # provider: "feishu-base" + # workflow: "reviewed-writeback-v1" # profile: "project-feishu" # baseToken: "" # tableId: "" @@ -23,6 +24,7 @@ project: # actual: "" # expected: "" # stepsToReproduce: "" + # fixLogic: "" # 启用飞书审核前协作时必填 # acceptance: "" # priority: "" # attachments: "" @@ -114,13 +116,24 @@ tasks: # 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。 # source: # kind: "feishu-base" + # workflow: "reviewed-writeback-v1" # ref: "feishu-base:sha256:<64-lowercase-hex>" # recordId: "" # updatedAt: "" + # approvedRevision: "sha256:" + # approvedPayloadHash: "sha256:" description: > + # 飞书审核通过后写入批准版本;审核前不创建这条 ACK 任务。 + fixLogic: > + + + acceptanceCriteria: + - "" + - "" + stepsToReproduce: - "" - "" diff --git a/tests/test_ack_feishu_intake_e2e.py b/tests/test_ack_feishu_intake_e2e.py index 3b5b340..1328485 100644 --- a/tests/test_ack_feishu_intake_e2e.py +++ b/tests/test_ack_feishu_intake_e2e.py @@ -236,6 +236,10 @@ tasks: [] self.assertIn("--brand feishu", content) self.assertTrue("scope" in content) self.assertIn("不要把 `lark-cli auth check`", content) + self.assertIn("审核通过前的唯一协作区", content) + self.assertIn("不创建或刷新 `tasks.yaml` 中的 ACK 任务", content) + self.assertIn("base:record:write", content) + self.assertIn("fixLogic", content) self.assertNotIn("--lark-cli", content) self.assertNotIn("--executable", content) @@ -248,8 +252,12 @@ tasks: [] self.assertEqual(empty_plan["actions"], [{ "sourceRef": first, "recordId": "recBug1", + "draftRevision": empty_plan["records"][0]["draftRevision"], "action": "create", }]) + self.assertRegex( + empty_plan["actions"][0]["draftRevision"], r"^sha256:[0-9a-f]{64}$", + ) base = self.board.read_text(encoding="utf-8") diff --git a/tests/test_ack_feishu_intake_unit.py b/tests/test_ack_feishu_intake_unit.py index 278b132..f50af3e 100644 --- a/tests/test_ack_feishu_intake_unit.py +++ b/tests/test_ack_feishu_intake_unit.py @@ -53,9 +53,13 @@ class FeishuBugIntakeUnitTests(unittest.TestCase): " 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" + " fields=['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated']; row=[' Bug\\n title ',' actual ','expected',['one','two'],'1. save succeeds\\n2. the crash no longer occurs','P1',[{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}],'2026-08-01']\n" + " if 'fldFixLogic' in args: fields.append('fldFixLogic'); row.append('change parser without widening input')\n" + " print(json.dumps({'code': 0, 'data': {'fields': fields, 'record_id_list': ['recA'], 'data': [row]}}))\n" "elif args[:2] == ['base', '+record-download-attachment']:\n" " out = pathlib.Path(args[args.index('--output') + 1]); out.mkdir(parents=True, exist_ok=True); (out / 'shot.png').write_bytes(b'png'); print(json.dumps({'ok': True, 'data': {}}))\n" + "elif args[:2] == ['base', '+record-upsert']:\n" + " print(json.dumps({'code': 0, 'data': {}}))\n" "else: raise SystemExit(2)\n", encoding="utf-8", ) @@ -210,6 +214,166 @@ class FeishuBugIntakeUnitTests(unittest.TestCase): } self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]])) + def test_optional_fix_logic_field_is_normalized_for_preapproval_review(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + board_path = Path(temp_dir) / "tasks.yaml" + board_path.write_text( + BOARD.replace( + " profile: tenant-b\n", + " workflow: reviewed-writeback-v1\n profile: tenant-b\n", + ).replace( + " acceptance: fldAcceptance\n", + " acceptance: fldAcceptance\n fixLogic: fldFixLogic\n", + ), + encoding="utf-8", + ) + config = feishu_bug_intake.config_from_board( + feishu_bug_intake.load_board(board_path) + ) + row = [ + "Bug", "actual", "expected", "steps", "acceptance", "P1", [], + "2026-08-01T12:00:00Z", "change parser without widening input", + ] + with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object( + feishu_bug_intake, "fetch_pages", return_value=[("recA", row)] + ): + payload = feishu_bug_intake.fetch(config, None) + + record = payload["records"][0] + self.assertEqual(record["fixLogic"], "change parser without widening input") + self.assertNotIn("fixLogic", record["enrichmentRequired"]) + + def test_write_draft_uses_trusted_cli_and_only_configured_review_fields(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + board_path = temp / "tasks.yaml" + board_path.write_text( + BOARD.replace( + " profile: tenant-b\n", + " workflow: reviewed-writeback-v1\n profile: tenant-b\n", + ).replace( + " acceptance: fldAcceptance\n", + " acceptance: fldAcceptance\n fixLogic: fldFixLogic\n", + ), + encoding="utf-8", + ) + draft_path = temp / "draft.json" + draft_path.write_text(json.dumps({ + "fixLogic": "change parser without widening input", + "acceptance": ["save succeeds", "the crash no longer occurs"], + }), encoding="utf-8") + fake, log_path = self.make_fake_cli(temp) + + fetched_code, fetched_output, fetched_error = self.invoke([ + "fetch", str(board_path), + ], fake) + self.assertEqual(fetched_code, 0, fetched_error) + fetched_record = json.loads(fetched_output)["records"][0] + + code, output, error = self.invoke([ + "write-draft", str(board_path), "--record-id", "recA", + "--expected-source-ref", fetched_record["sourceRef"], + "--expected-draft-revision", fetched_record["draftRevision"], + "--input", str(draft_path), + ], fake) + + self.assertEqual(code, 0, error) + self.assertEqual(json.loads(output)["written"], ["fixLogic", "acceptance"]) + self.assertRegex(json.loads(output)["draftRevision"], r"^sha256:[0-9a-f]{64}$") + calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()] + write_call = next(call for call in calls if call[:2] == ["base", "+record-upsert"]) + self.assertEqual(write_call[write_call.index("--profile") + 1], "tenant-b") + patch = json.loads(write_call[write_call.index("--json") + 1]) + self.assertEqual(set(patch), {"fldFixLogic", "fldAcceptance"}) + self.assertIn("1. save succeeds", patch["fldAcceptance"]) + + imported_code, imported_output, imported_error = self.invoke([ + "import-approved", str(board_path), "--record-id", "recA", + "--expected-source-ref", fetched_record["sourceRef"], + "--expected-draft-revision", fetched_record["draftRevision"], + ], fake) + self.assertEqual(imported_code, 0, imported_error) + task_draft = json.loads(imported_output)["taskDraft"] + self.assertEqual(task_draft["source"]["workflow"], "reviewed-writeback-v1") + self.assertEqual( + task_draft["acceptanceCriteria"], + ["save succeeds", "the crash no longer occurs"], + ) + self.assertRegex( + task_draft["source"]["approvedPayloadHash"], r"^sha256:[0-9a-f]{64}$", + ) + + def test_write_draft_rejects_stale_revision_before_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + board_path = temp / "tasks.yaml" + board_path.write_text( + BOARD.replace( + " profile: tenant-b\n", + " workflow: reviewed-writeback-v1\n profile: tenant-b\n", + ).replace( + " acceptance: fldAcceptance\n", + " acceptance: fldAcceptance\n fixLogic: fldFixLogic\n", + ), + encoding="utf-8", + ) + draft_path = temp / "draft.json" + draft_path.write_text(json.dumps({ + "fixLogic": "fix", + "acceptance": ["pass"], + }), encoding="utf-8") + fake, log_path = self.make_fake_cli(temp) + fetched = json.loads(self.invoke(["fetch", str(board_path)], fake)[1])["records"][0] + + code, _, _ = self.invoke([ + "write-draft", str(board_path), "--record-id", "recA", + "--expected-source-ref", fetched["sourceRef"], + "--expected-draft-revision", "sha256:" + "0" * 64, + "--input", str(draft_path), + ], fake) + + self.assertNotEqual(code, 0) + calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()] + self.assertFalse(any(call[:2] == ["base", "+record-upsert"] for call in calls)) + + def test_write_draft_rejects_symlink_and_unknown_fields(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + target = temp / "draft.json" + target.write_text(json.dumps({ + "fixLogic": "fix", + "acceptance": ["pass"], + "unexpected": "do not write", + }), encoding="utf-8") + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.load_draft(target) + + target.write_text(json.dumps({ + "fixLogic": "fix", + "acceptance": ["pass"], + }), encoding="utf-8") + linked = temp / "linked.json" + linked.symlink_to(target) + with self.assertRaises(feishu_bug_intake.IntakeError): + feishu_bug_intake.load_draft(linked) + + def test_draft_revision_binds_attachment_identity(self) -> None: + record = { + "sourceRef": "feishu-base:sha256:" + "a" * 64, + "updatedAt": "2026-08-03T12:00:00+08:00", + "title": "Bug", + "actual": "crash", + "expected": "save", + "steps": "open then save", + "fixLogic": "fix parser", + "acceptance": "save succeeds", + "priority": "P1", + "attachments": [{"name": "same.png", "type": "image/png", "size": 4}], + } + first = feishu_bug_intake.draft_revision(record, ["fileTokenA"]) + second = feishu_bug_intake.draft_revision(record, ["fileTokenB"]) + self.assertNotEqual(first, second) + def test_attachments_and_required_text_are_resource_bounded_and_strict(self) -> None: with self.assertRaises(feishu_bug_intake.IntakeError): feishu_bug_intake.attachment_items([{ diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 52df198..128902f 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -67,7 +67,7 @@ class AckSkillContentTests(unittest.TestCase): ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip() - self.assertEqual(version, "0.13.1") + self.assertEqual(version, "0.14.0") self.assertIn( f'ackVersion: "{version}"', (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py index 78ea71f..42bcf22 100644 --- a/tests/test_ack_tasks_validation.py +++ b/tests/test_ack_tasks_validation.py @@ -16,6 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1] VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py" EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml" SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json" +sys.path.insert(0, str(VALIDATOR.parent)) +from approval_payload import approval_payload_hash # noqa: E402 def valid_knowledge_board() -> dict: @@ -148,6 +150,93 @@ class AckTaskValidationTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("任务板校验通过", result.stdout) + def test_approved_feishu_plan_fields_are_validated(self) -> None: + board = valid_knowledge_board() + board["tasks"][0]["fixLogic"] = "change the parser and preserve legacy input" + board["tasks"][0]["acceptanceCriteria"] = [ + "the submitted value is saved", + "the original crash no longer occurs", + ] + self.assert_board_accepted_in_all_modes(board) + + invalid = copy.deepcopy(board) + invalid["tasks"][0]["acceptanceCriteria"] = ["valid", 7] + self.assert_board_rejected_in_all_modes(invalid, "列表项必须是字符串") + + def test_reviewed_feishu_workflow_requires_fix_logic_and_approval_revision(self) -> None: + fields = { + "title": "fTitle", + "actual": "fActual", + "expected": "fExpected", + "stepsToReproduce": "fSteps", + "acceptance": "fAcceptance", + "priority": "fPriority", + "attachments": "fAttachments", + "updatedAt": "fUpdated", + } + board = valid_knowledge_board() + board["project"]["bugIntake"] = { + "provider": "feishu-base", + "workflow": "reviewed-writeback-v1", + "profile": "tenant-b", + "baseToken": "baseToken", + "tableId": "tblBugs", + "viewId": "vewReview", + "fields": fields, + } + self.assert_board_rejected_in_all_modes( + board, "reviewed-writeback-v1 必须映射", + ) + + board["project"]["bugIntake"]["fields"]["fixLogic"] = "fFixLogic" + board["tasks"][0].update({ + "priority": "P1", + "actual": "save crashes", + "expected": "save succeeds", + "stepsToReproduce": ["open", "save"], + "fixLogic": "preserve input while fixing the parser", + "acceptanceCriteria": ["save succeeds", "the crash no longer occurs"], + }) + board["tasks"][0]["source"] = { + "kind": "feishu-base", + "workflow": "reviewed-writeback-v1", + "ref": "feishu-base:sha256:" + "a" * 64, + "recordId": "recA", + "updatedAt": "2026-08-03T12:00:00+08:00", + "approvedRevision": "sha256:" + "b" * 64, + } + board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash( + board["tasks"][0] + ) + self.assert_board_accepted_in_all_modes(board) + + board["tasks"][0]["source"]["approvedRevision"] = "latest" + self.assert_board_rejected_in_all_modes(board, "必须是 sha256 revision") + + board["tasks"][0]["source"]["approvedRevision"] = "sha256:" + "b" * 64 + board["tasks"][0]["fixLogic"] = "silently changed after approval" + self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配") + + board["tasks"][0]["fixLogic"] = "preserve input while fixing the parser" + board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash( + board["tasks"][0] + ) + board["tasks"][0]["description"] = "modify unrelated modules" + self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配") + + legacy = copy.deepcopy(board) + legacy["tasks"][0]["description"] = "validate knowledge fields" + legacy["tasks"][0]["source"] = { + "kind": "feishu-base", + "ref": "feishu-base:sha256:" + "c" * 64, + "recordId": "recLegacy", + "updatedAt": "2026-08-01T12:00:00+08:00", + } + self.assert_board_rejected_in_all_modes(legacy, "必须先迁移审核") + + legacy["tasks"][0]["status"] = "verified" + self.assert_board_accepted_in_all_modes(legacy) + def test_legacy_sources_remain_open_while_feishu_sources_are_strict(self) -> None: for legacy_source in ( "manual",