feat: update feishu intake

This commit is contained in:
2026-08-02 14:08:11 +08:00
parent e9b34747cc
commit 6855ac7078
13 changed files with 535 additions and 23 deletions
+13 -3
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
0.12.0
0.13.1
+1 -1
View File
@@ -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 指令文件。
+1 -1
View File
@@ -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"
+18 -1
View File
@@ -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。
+15 -2
View File
@@ -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、构建产物、
+29 -11
View File
@@ -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
+261
View File
@@ -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())