feat: update feishu intake
This commit is contained in:
@@ -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 的部分记录失败。
|
||||
|
||||
## 最脆弱假设与降级
|
||||
|
||||
|
||||
+13
-3
@@ -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 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
||||
--task-id BUG-001
|
||||
|
||||
python3 <ack-skill-dir>/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`。
|
||||
|
||||
+13
-1
@@ -83,6 +83,9 @@ description: >-
|
||||
- `docs/ack/tasks.yaml`
|
||||
- `docs/ack/knowledge.yaml`
|
||||
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
|
||||
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
|
||||
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
|
||||
2. 读取 `<ack-skill-dir>/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 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml`,只读取
|
||||
`project`、`summary` 和默认可工作状态的任务;已知当前任务时传
|
||||
`--task-id <ack-task-id>`。选择器会解析并校验完整任务板,并只附带选中任务引用的
|
||||
receipt 与 delivery run。命中超过默认预算时用 `--task-id` / `--status` 缩小,
|
||||
不直接回退为输出完整 `tasks.yaml`。
|
||||
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
|
||||
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
||||
- `<ack-skill-dir>/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 目录读取。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.12.0
|
||||
0.13.1
|
||||
|
||||
@@ -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 指令文件。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
||||
`project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、
|
||||
不匹配的角色/档位和非安全权限必须在派发前 fail closed。
|
||||
|
||||
读取任务上下文使用:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
||||
--task-id <ack-task-id>
|
||||
```
|
||||
|
||||
选择器解析并校验完整任务板,但只输出命中任务及其 receipt/delivery run;超过预算时
|
||||
显式失败,不得改用 `cat` 或整文件 `sed`。
|
||||
|
||||
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
||||
|
||||
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Executable
+261
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user