From 6855ac7078fe6dd5f075273f933ae76f53570d71 Mon Sep 17 00:00:00 2001 From: laily Date: Sun, 2 Aug 2026 14:08:11 +0800 Subject: [PATCH] feat: update feishu intake --- docs/ack-feishu-bug-intake.md | 8 + skills/ack/README.md | 16 +- skills/ack/SKILL.md | 14 +- skills/ack/VERSION | 2 +- skills/ack/examples/project.example.md | 2 +- skills/ack/examples/tasks.example.yaml | 2 +- skills/ack/references/feishu-bug-intake.md | 19 +- skills/ack/references/kickoff.md | 17 +- skills/ack/scripts/feishu_bug_intake.py | 40 +++- skills/ack/scripts/select_tasks.py | 261 +++++++++++++++++++++ tests/test_ack_feishu_intake_e2e.py | 31 ++- tests/test_ack_skill.py | 4 +- tests/test_ack_task_selector.py | 142 +++++++++++ 13 files changed, 535 insertions(+), 23 deletions(-) create mode 100755 skills/ack/scripts/select_tasks.py create mode 100644 tests/test_ack_task_selector.py diff --git a/docs/ack-feishu-bug-intake.md b/docs/ack-feishu-bug-intake.md index f31cd2c..94a14dc 100644 --- a/docs/ack-feishu-bug-intake.md +++ b/docs/ack-feishu-bug-intake.md @@ -54,6 +54,7 @@ 后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本; - `recordId` 与 `updatedAt`; - title、actual、expected、steps、acceptance、priority; +- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、acceptance、priority; - 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用; - 原始字段中无法映射但不影响导入的警告。 @@ -71,6 +72,11 @@ token 或 CLI 配置文件内容。 5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。 6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。 7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。 +8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed; + steps、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文补齐, + 并写入 `evidence.intakeEnrichment`,不要求报告者返回飞书机械补录。 +9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分 + 来源事实的残缺行不得静默跳过。 ## 可观测验收信号 @@ -80,6 +86,8 @@ token 或 CLI 配置文件内容。 4. CLI 缺失、profile 缺失、畸形 JSON、错位矩阵、分页越界、路径穿越或附件下载失败均返回非零退出码且不输出伪成功结果。 5. ACK 文档明确要求按 `source.ref` 幂等整理;同一读取结果重复提交不会生成第二个来源任务。 6. 现有无 `bugIntake` 的 ACK 项目仍能通过任务板校验并按原流程工作。 +7. 缺少 steps、acceptance、priority 的记录仍生成 create/refresh 计划并列出 + `enrichmentRequired`;全空误建行被跳过,缺 title/actual/expected 的部分记录失败。 ## 最脆弱假设与降级 diff --git a/skills/ack/README.md b/skills/ack/README.md index c322765..91a0b2a 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -59,7 +59,7 @@ skills/ack/ ├── references/ # 三角色规范、闭环流程和初始化说明 ├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema ├── examples/ # 完整示例 -└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher +└── scripts/ # 状态校验、任务/知识选择、安全验证执行与结构化 worker launcher ``` `SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范; @@ -84,10 +84,19 @@ python3 /scripts/validate_delivery.py docs/ack/delivery.yaml \ Coordinator 可以按当前任务上下文做确定性推荐: ```bash +python3 /scripts/select_tasks.py docs/ack/tasks.yaml +python3 /scripts/select_tasks.py docs/ack/tasks.yaml \ + --task-id BUG-001 + python3 /scripts/select_knowledge.py docs/ack/knowledge.yaml \ --component web --path web/app.py --tag long-running-service --limit 10 ``` +任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、 +默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery +run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入 +上下文。 + 默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`; 选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用 `--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。 @@ -223,5 +232,6 @@ 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.13.0` 起,Coordinator 使用 +`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型。旧项目可以不迁移 +而继续使用原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 15917ff..09338a0 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -83,6 +83,9 @@ description: >- - `docs/ack/tasks.yaml` - `docs/ack/knowledge.yaml` - `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验) + 需要查看任务内容时,使用 `/scripts/select_tasks.py` 解析完整任务板并 + 只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整 + `tasks.yaml` 注入上下文。完整性仍由校验器检查。 2. 读取 `/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有 `kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法 SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时 @@ -114,7 +117,11 @@ description: >- 1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。 2. 依次读取: - `docs/ack/project.md` - - `docs/ack/tasks.yaml` + - 运行 `python3 /scripts/select_tasks.py docs/ack/tasks.yaml`,只读取 + `project`、`summary` 和默认可工作状态的任务;已知当前任务时传 + `--task-id `。选择器会解析并校验完整任务板,并只附带选中任务引用的 + receipt 与 delivery run。命中超过默认预算时用 `--task-id` / `--status` 缩小, + 不直接回退为输出完整 `tasks.yaml`。 - 通过 `/scripts/select_knowledge.py` 从 `docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目 - `/references/kickoff.md` @@ -127,6 +134,9 @@ description: >- 不全量注入知识库。 配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check, 再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。 + `enrichmentRequired` 中的复现步骤、验收标准和优先级由 Coordinator 根据来源事实与 + 项目上下文补齐,并在任务证据中标记推断字段,不要求用户回飞书机械补录;标题、实际 + 表现和预期结果不可推断,部分缺失时仍停止。整行空白记录按批次 warning 跳过。 按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述; `dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。 @@ -191,6 +201,8 @@ description: >- 维护中修改。 - 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py` 按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 +- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务 + 视图,写回前仍运行完整任务板校验。 - 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、 `docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前 ACK Skill 目录读取。 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index ac454c6..c317a91 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.12.0 +0.13.1 diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index cf0c228..b9fb5e1 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,6 +1,6 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.12.0。 +> 本项目基于 ack v0.13.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 6f08615..72c5887 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.12.0" +ackVersion: "0.13.0" project: name: "notes-web" repoPath: "/home/dev/notes-web" diff --git a/skills/ack/references/feishu-bug-intake.md b/skills/ack/references/feishu-bug-intake.md index 94a6ce1..59629f1 100644 --- a/skills/ack/references/feishu-bug-intake.md +++ b/skills/ack/references/feishu-bug-intake.md @@ -53,7 +53,10 @@ profile、JSON、分页、附件或路径任一异常都会失败且不输出伪 `plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为 `create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为 `unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复 -`sourceRef` 会直接失败。 +`sourceRef` 会直接失败。标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时 +读取失败;复现步骤、验收标准和优先级属于 Coordinator 可整理字段,缺失时记录与 action +会返回 `enrichmentRequired`,不阻断整批。没有附件且所有 Bug 内容字段都为空的误建行会 +跳过,并在批次 `warnings` 中返回 `blank_record_skipped`。 子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的 `LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时 @@ -74,3 +77,17 @@ profile、JSON、分页、附件或路径任一异常都会失败且不输出伪 `source.ref` 查重。相同来源只更新 `open` 任务;`dispatched`、`fixed_by_dev`、 `retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移, 由用户决定是否新建任务;飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。 + +当 action 带 `enrichmentRequired` 时,由 Coordinator 补齐后再写入任务板,不要求报告者 +返回飞书机械补字段: + +- `steps`:根据标题、实际表现、附件和项目现状整理 2 至 5 个可复现步骤;证据不足时明确 + 写成待 Developer 首轮确认的复现假设,不能把假设伪装成来源原文。 +- `acceptance`:把预期结果改写成可观测信号,至少覆盖用户可见结果、真实状态或 API 结果, + 以及原失败不再出现;不扩张飞书记录没有表达的产品范围。 +- `priority`:主流程阻断、数据损坏/丢失或安全风险定为 P0;普通功能错误默认 P1;纯样式或 + 低频体验问题可定为 P2。无法判断时使用 P1,并保留判断依据。 + +在任务 `evidence.intakeEnrichment` 中记录哪些字段由 Coordinator 推断及依据。Developer +必须先复现或用失败测试确认推断步骤;Test 仍按任务中的可观测验收信号独立复测。标题、 +实际表现和预期结果不得由 Coordinator 补造,部分缺失时继续 fail closed。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index 959122a..97e32c6 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -20,8 +20,10 @@ 我要做一个新需求:<一句话需求>。 你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行: -1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并 - 用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读 +1. 先读 docs/ack/project.md,并用 `scripts/select_tasks.py docs/ack/tasks.yaml` + 读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把 + 完整 tasks.yaml 注入上下文。校验 docs/ack/knowledge.yaml 并用 + `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读 references/roles-and-permissions.md、closed-loop.md、optimization-method.md。 如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与 references/delivery.md,但不要把配置本身当作执行授权。 @@ -58,6 +60,17 @@ python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml -- `project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、 不匹配的角色/档位和非安全权限必须在派发前 fail closed。 + 读取任务上下文使用: + +```bash +python3 /scripts/select_tasks.py docs/ack/tasks.yaml +python3 /scripts/select_tasks.py docs/ack/tasks.yaml \ + --task-id +``` + + 选择器解析并校验完整任务板,但只输出命中任务及其 receipt/delivery run;超过预算时 + 显式失败,不得改用 `cat` 或整文件 `sed`。 + 5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。 如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、 diff --git a/skills/ack/scripts/feishu_bug_intake.py b/skills/ack/scripts/feishu_bug_intake.py index 8d5fc20..02c7f16 100644 --- a/skills/ack/scripts/feishu_bug_intake.py +++ b/skills/ack/scripts/feishu_bug_intake.py @@ -26,6 +26,9 @@ 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") +SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt") +COORDINATOR_FIELDS = ("steps", "acceptance", "priority") +BUG_CONTENT_FIELDS = ("title", "actual", "expected", *COORDINATOR_FIELDS) MAX_PAGES = 100 MAX_RECORDS = 10_000 PAGE_SIZE = 100 @@ -444,6 +447,7 @@ def source_ref(config: dict[str, Any], record_id: str) -> str: def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: profile_check(config) prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = [] + batch_warnings: list[dict[str, str]] = [] total_attachments = 0 total_attachment_bytes = 0 for record_id, row in fetch_pages(config): @@ -456,9 +460,15 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]: 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 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") + record["enrichmentRequired"] = [ + field for field in COORDINATOR_FIELDS if not record[field] + ] prepared.append((record, attachment_data)) download_root: Path | None = None if output_dir is not None: @@ -483,10 +493,10 @@ 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} + return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings} -def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, str]]: +def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]: """Plan idempotent Coordinator actions without mutating the task board.""" tasks = board.get("tasks") if not isinstance(tasks, list): @@ -513,7 +523,7 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d raise IntakeError("task board contains duplicate Feishu source references") existing[ref] = task - actions: list[dict[str, str]] = [] + actions: list[dict[str, Any]] = [] seen_records: set[str] = set() for record in records: ref = record.get("sourceRef") @@ -530,22 +540,30 @@ 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: - actions.append({"sourceRef": ref, "recordId": record_id, "action": "create"}) + planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "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: - action = "unchanged" + action_name = "unchanged" elif task["status"] == "open": - action = "refresh" + action_name = "refresh" else: - action = "drift" - actions.append({ + action_name = "drift" + planned_action = { "sourceRef": ref, "recordId": record_id, "taskId": task["id"], "status": task["status"], - "action": action, - }) + "action": action_name, + } + enrichment_required = record.get("enrichmentRequired") + if enrichment_required and action_name == "refresh": + planned_action["enrichmentRequired"] = enrichment_required + actions.append(planned_action) return actions diff --git a/skills/ack/scripts/select_tasks.py b/skills/ack/scripts/select_tasks.py new file mode 100755 index 0000000..f0e117a --- /dev/null +++ b/skills/ack/scripts/select_tasks.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""为 ACK Coordinator 输出有预算的任务板上下文。 + +脚本会解析并校验完整 tasks.yaml,但只输出项目配置、摘要、可工作任务或显式任务, +以及这些任务引用的 worker receipt 和 delivery run。它不会修改任务板,也不会静默 +截断超过预算的任务集合。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from validate_tasks import STATUS_ENUM, load_document, validate_builtin # type: ignore + + +ACTIONABLE_STATUSES = ( + "open", + "dispatched", + "fixed_by_dev", + "retesting", + "failed_retest", +) +DEFAULT_LIMIT = 20 +MAX_LIMIT = 100 +MAX_OUTPUT_BYTES = 512 * 1024 +BOARD_METADATA_FIELDS = ( + "version", + "updatedAt", + "source", + "ackVersion", + "kitVersion", + "testRecord", + "statusReference", +) + + +class SelectionError(ValueError): + pass + + +def _unique(values: list[str]) -> list[str]: + return list(dict.fromkeys(values)) + + +def select_tasks( + data: dict[str, Any], + *, + task_ids: list[str] | None = None, + statuses: list[str] | None = None, + limit: int = DEFAULT_LIMIT, +) -> tuple[str, list[str], list[dict[str, Any]]]: + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT: + raise SelectionError(f"limit 必须在 1..{MAX_LIMIT} 之间") + tasks = data.get("tasks") + if not isinstance(tasks, list): + raise SelectionError("tasks 必须是列表") + + requested_ids = _unique(task_ids or []) + requested_statuses = _unique(statuses or []) + if requested_ids and requested_statuses: + raise SelectionError("--task-id 与 --status 不能同时使用") + + if requested_ids: + by_id = { + task.get("id"): task + for task in tasks + if isinstance(task, dict) and isinstance(task.get("id"), str) + } + missing = [task_id for task_id in requested_ids if task_id not in by_id] + if missing: + raise SelectionError("找不到任务: " + ", ".join(missing)) + selected = [by_id[task_id] for task_id in requested_ids] + mode = "task_ids" + criteria = requested_ids + else: + effective_statuses = requested_statuses or list(ACTIONABLE_STATUSES) + unknown = [status for status in effective_statuses if status not in STATUS_ENUM] + if unknown: + raise SelectionError("未知状态: " + ", ".join(unknown)) + wanted = set(effective_statuses) + selected = [ + task + for task in tasks + if isinstance(task, dict) and task.get("status") in wanted + ] + mode = "statuses" + criteria = effective_statuses + + if len(selected) > limit: + raise SelectionError( + f"命中 {len(selected)} 条任务,超过 --limit={limit};" + "请用 --task-id/--status 缩小范围或显式提高 limit" + ) + return mode, criteria, selected + + +def select_referenced_receipts( + data: dict[str, Any], selected: list[dict[str, Any]] +) -> list[dict[str, Any]]: + receipt_ids: list[str] = [] + for task in selected: + dispatch = task.get("dispatch") + if not isinstance(dispatch, dict): + continue + for role in ("developer", "test"): + role_dispatch = dispatch.get(role) + if not isinstance(role_dispatch, dict): + continue + receipt_id = role_dispatch.get("receiptId") + if isinstance(receipt_id, str) and receipt_id not in receipt_ids: + receipt_ids.append(receipt_id) + if not receipt_ids: + return [] + + receipts = data.get("workerReceipts") + if not isinstance(receipts, list): + raise SelectionError("选中任务引用了 receipt,但 workerReceipts 不是列表") + by_id = { + receipt.get("id"): receipt + for receipt in receipts + if isinstance(receipt, dict) and isinstance(receipt.get("id"), str) + } + missing = [receipt_id for receipt_id in receipt_ids if receipt_id not in by_id] + if missing: + raise SelectionError("选中任务引用了未知 receipt: " + ", ".join(missing)) + return [by_id[receipt_id] for receipt_id in receipt_ids] + + +def select_delivery_runs( + data: dict[str, Any], selected: list[dict[str, Any]] +) -> list[dict[str, Any]]: + selected_ids = { + task.get("id") + for task in selected + if isinstance(task.get("id"), str) + } + runs = data.get("deliveryRuns") + if not selected_ids or not isinstance(runs, list): + return [] + return [ + run + for run in runs + if isinstance(run, dict) + and isinstance(run.get("taskIds"), list) + and any(task_id in selected_ids for task_id in run["taskIds"]) + ] + + +def build_payload( + data: dict[str, Any], + *, + mode: str, + criteria: list[str], + selected: list[dict[str, Any]], +) -> dict[str, Any]: + receipts = select_referenced_receipts(data, selected) + delivery_runs = select_delivery_runs(data, selected) + tasks = data.get("tasks") + metadata = { + field: data[field] + for field in BOARD_METADATA_FIELDS + if field in data + } + selected_ids = [ + task["id"] + for task in selected + if isinstance(task.get("id"), str) + ] + payload: dict[str, Any] = { + "board": metadata, + "project": data.get("project"), + "summary": data.get("summary"), + "selection": { + "mode": mode, + "criteria": criteria, + "count": len(selected), + "totalTasks": len(tasks) if isinstance(tasks, list) else 0, + "selectedTaskIds": selected_ids, + "referencedReceiptIds": [receipt.get("id") for receipt in receipts], + "deliveryRunIds": [run.get("id") for run in delivery_runs], + }, + "tasks": selected, + "workerReceipts": receipts, + "deliveryRuns": delivery_runs, + } + return payload + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="选择 ACK 任务板的有预算 Coordinator 上下文" + ) + parser.add_argument( + "tasks", + nargs="?", + default="docs/ack/tasks.yaml", + help="任务板路径", + ) + parser.add_argument("--task-id", action="append", default=[]) + parser.add_argument( + "--status", + action="append", + default=[], + choices=sorted(STATUS_ENUM), + ) + parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT) + parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + tasks_path = Path(args.tasks) + if not tasks_path.is_file(): + sys.stderr.write(f"找不到任务板文件: {tasks_path}\n") + return 2 + + data = load_document(tasks_path) + errors = validate_builtin(data) + if errors: + sys.stderr.write(f"任务板无效,拒绝选择,共 {len(errors)} 项:\n") + for error in errors: + sys.stderr.write(f" - {error}\n") + return 1 + + try: + mode, criteria, selected = select_tasks( + data, + task_ids=args.task_id, + statuses=args.status, + limit=args.limit, + ) + payload = build_payload( + data, + mode=mode, + criteria=criteria, + selected=selected, + ) + except SelectionError as exc: + sys.stderr.write(f"任务选择失败: {exc}\n") + return 1 + + if args.compact: + output = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + else: + output = json.dumps(payload, ensure_ascii=False, indent=2) + if len(output.encode("utf-8")) > MAX_OUTPUT_BYTES: + sys.stderr.write( + f"任务选择输出超过 {MAX_OUTPUT_BYTES} bytes;请进一步缩小任务范围\n" + ) + return 1 + sys.stdout.write(output + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ack_feishu_intake_e2e.py b/tests/test_ack_feishu_intake_e2e.py index 67cc008..3b5b340 100644 --- a/tests/test_ack_feishu_intake_e2e.py +++ b/tests/test_ack_feishu_intake_e2e.py @@ -58,7 +58,9 @@ class FeishuIntakeBlackBox(unittest.TestCase): " 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" + " if mode=='empty': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title',None,'expected','steps','accept','P1',[],fixture['updatedAt']]]}})); raise SystemExit\n" + " if mode=='enrichment': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title','actual','expected',None,None,None,[],fixture['updatedAt']]]}})); raise SystemExit\n" + " if mode=='blank': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBlank'],'data':[[None,None,None,None,None,None,[],fixture['updatedAt']]]}})); raise SystemExit\n" " f=fixture; print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[f['recordId']],'data':[[f['title'],f['actual'],f['expected'],f['steps'],f['acceptance'],f['priority'],f['attachments'],f['updatedAt']]]}})); raise SystemExit\n" "if a[:2]==['base','+record-download-attachment']:\n" " out=pathlib.Path(a[a.index('--output')+1]); out.mkdir(parents=True,exist_ok=True)\n" @@ -165,6 +167,33 @@ tasks: [] result = self.invoke("fetch", mode="escape", output=self.d / "downloads") self.assertNotEqual(result.returncode, 0) + def test_missing_coordinator_fields_are_planned_for_enrichment(self) -> None: + fetched = self.invoke("fetch", mode="enrichment") + self.assertEqual(fetched.returncode, 0, fetched.stderr) + payload = json.loads(fetched.stdout) + self.assertEqual(payload["warnings"], []) + self.assertEqual(payload["records"][0]["enrichmentRequired"], [ + "steps", "acceptance", "priority", + ]) + + planned = self.invoke("plan", mode="enrichment") + self.assertEqual(planned.returncode, 0, planned.stderr) + action = json.loads(planned.stdout)["actions"][0] + self.assertEqual(action["action"], "create") + self.assertEqual(action["enrichmentRequired"], [ + "steps", "acceptance", "priority", + ]) + + def test_fully_blank_rows_are_skipped_with_a_batch_warning(self) -> None: + result = self.invoke("fetch", mode="blank") + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["records"], []) + self.assertEqual(payload["warnings"], [{ + "recordId": "recBlank", + "code": "blank_record_skipped", + }]) + def test_resolver_trust_and_no_executable_override_surface(self) -> None: trusted, hostile = self.d / "trusted", self.d / "hostile" trusted.mkdir(); hostile.mkdir() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 5556b10..dde3028 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -21,6 +21,7 @@ class AckSkillContentTests(unittest.TestCase): "validate_tasks.py", "validate_knowledge.py", "validate_delivery.py", + "select_tasks.py", "select_knowledge.py", "references/kickoff.md", "不要修改项目的 `AGENTS.md`", @@ -54,6 +55,7 @@ class AckSkillContentTests(unittest.TestCase): "templates/knowledge.schema.json", "examples/knowledge.example.yaml", "scripts/validate_knowledge.py", + "scripts/select_tasks.py", "scripts/select_knowledge.py", "scripts/run_verification.py", "scripts/worker_profiles.py", @@ -65,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.12.0") + self.assertEqual(version, "0.13.0") self.assertIn( f'ackVersion: "{version}"', (ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"), diff --git a/tests/test_ack_task_selector.py b/tests/test_ack_task_selector.py new file mode 100644 index 0000000..ab99c92 --- /dev/null +++ b/tests/test_ack_task_selector.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SELECTOR = REPO_ROOT / "skills" / "ack" / "scripts" / "select_tasks.py" +EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml" +SPEC = importlib.util.spec_from_file_location("ack_select_tasks", SELECTOR) +assert SPEC and SPEC.loader +SELECT_TASKS = importlib.util.module_from_spec(SPEC) +sys.path.insert(0, str(SELECTOR.parent)) +SPEC.loader.exec_module(SELECT_TASKS) + + +def board() -> dict: + return { + "version": 1, + "updatedAt": "2026-08-01T20:00:00+08:00", + "source": "Coordinator", + "project": {"name": "demo"}, + "summary": {"open": ["T-OPEN"], "verified": ["T-DONE"]}, + "tasks": [ + {"id": "T-OPEN", "title": "open", "status": "open"}, + {"id": "T-DEV", "title": "dev", "status": "fixed_by_dev"}, + {"id": "T-BLOCKED", "title": "blocked", "status": "blocked"}, + {"id": "T-DONE", "title": "done", "status": "verified"}, + ], + } + + +class AckTaskSelectorTests(unittest.TestCase): + def run_selector( + self, + value: dict, + *args: str, + no_site_packages: bool = False, + ) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "tasks.json" + path.write_text(json.dumps(value), encoding="utf-8") + command = [sys.executable] + if no_site_packages: + command.append("-S") + command.extend((str(SELECTOR), str(path), *args)) + return subprocess.run( + command, + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + def test_default_selects_only_actionable_tasks_in_all_modes(self) -> None: + for no_site_packages in (False, True): + with self.subTest(no_site_packages=no_site_packages): + result = self.run_selector(board(), no_site_packages=no_site_packages) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual( + payload["selection"]["selectedTaskIds"], + ["T-OPEN", "T-DEV"], + ) + self.assertEqual(payload["selection"]["totalTasks"], 4) + self.assertEqual(payload["project"]["name"], "demo") + self.assertEqual(payload["summary"]["open"], ["T-OPEN"]) + + def test_example_yaml_runs_without_site_packages(self) -> None: + result = subprocess.run( + [sys.executable, "-S", str(SELECTOR), str(EXAMPLE), "--compact"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["project"]["name"], "notes-web") + self.assertEqual(payload["selection"]["mode"], "statuses") + + def test_explicit_task_id_selects_terminal_task(self) -> None: + result = self.run_selector(board(), "--task-id", "T-DONE") + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["selection"]["mode"], "task_ids") + self.assertEqual(payload["selection"]["selectedTaskIds"], ["T-DONE"]) + self.assertEqual([task["id"] for task in payload["tasks"]], ["T-DONE"]) + + def test_missing_task_and_over_budget_fail_closed(self) -> None: + missing = self.run_selector(board(), "--task-id", "T-MISSING") + self.assertEqual(missing.returncode, 1) + self.assertIn("找不到任务", missing.stderr) + + over_budget = self.run_selector(board(), "--limit", "1") + self.assertEqual(over_budget.returncode, 1) + self.assertIn("超过 --limit=1", over_budget.stderr) + + def test_invalid_board_is_rejected_before_selection(self) -> None: + invalid = board() + invalid["tasks"].append( + {"id": "T-OPEN", "title": "duplicate", "status": "open"} + ) + result = self.run_selector(invalid) + self.assertEqual(result.returncode, 1) + self.assertIn("任务板无效", result.stderr) + self.assertIn("id 重复", result.stderr) + + def test_receipts_and_delivery_runs_are_reduced_to_selected_tasks(self) -> None: + selected = [ + { + "id": "T-1", + "dispatch": { + "developer": {"receiptId": "WR-1"}, + "test": {"receiptId": None}, + }, + } + ] + data = { + "workerReceipts": [{"id": "WR-1"}, {"id": "WR-2"}], + "deliveryRuns": [ + {"id": "DR-1", "taskIds": ["T-1"]}, + {"id": "DR-2", "taskIds": ["T-2"]}, + ], + } + self.assertEqual( + SELECT_TASKS.select_referenced_receipts(data, selected), + [{"id": "WR-1"}], + ) + self.assertEqual( + SELECT_TASKS.select_delivery_runs(data, selected), + [{"id": "DR-1", "taskIds": ["T-1"]}], + ) + + +if __name__ == "__main__": + unittest.main()