feat(ack): shard task records for merge-safe workspaces

This commit is contained in:
2026-09-20 19:15:43 +08:00
parent fd13dad2b0
commit 130dcc36c5
29 changed files with 985 additions and 218 deletions
+10 -2
View File
@@ -55,7 +55,8 @@ pouch init ack --project ~/code/my-app
```text
.pouch/ack/
├── project.md
├── tasks.yaml
├── tasks.yaml # 项目配置与 taskStore 指针
├── task-records/ # 追加式任务/执行记录
├── knowledge.yaml
├── delivery.yaml # 默认 enabled: false
├── regression.yaml # 默认 cases: []
@@ -64,7 +65,14 @@ pouch init ack --project ~/code/my-app
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件。
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件;需要消除单文件冲突时,先运行 `scripts/migrate_tasks.py` 的 dry-run。
任务状态追加示例:
```bash
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial
```
## 使用示例
+18 -3
View File
@@ -8,12 +8,14 @@ description: >-
# ACK 项目协作入口
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
`delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
`CLAUDE.md`。
固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
固定配置文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`;任务执行记录从
`.pouch/ack/task-records/` 追加读取。旧项目没有 `taskStore` 时仍兼容直接读取
`tasks.yaml`。固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和
`.pouch/ack/regression.yaml`。
@@ -38,7 +40,10 @@ description: >-
- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。
- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。
- 只有 Coordinator 写任务板、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 新项目不要把任务状态、worker receipt 或运行历史回写到 `tasks.yaml`;每次状态变化写入
`task-records/<recordId>.yaml`,记录必须包含 `taskId`、`parents` 和完整 `task` 快照。
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
- 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
@@ -61,9 +66,19 @@ python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yam
# 仅在存在临时角色配置时运行
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
旧项目迁移前先预览 `python3 <ack-skill-dir>/scripts/migrate_tasks.py .pouch/ack/tasks.yaml`,确认后再加 `--apply`。
追加任务状态时使用完整快照 JSON,不修改已有记录:
```bash
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id <task-id> --task-json <task-snapshot.json> \
--parent <previous-record-id> --kind task.snapshot
```
```text
## ack 初始化:完成 | 部分完成 | 阻塞
+6 -4
View File
@@ -4,7 +4,7 @@
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml`
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、`delivery.yaml`
> 与 `regression.yaml`。
## 项目概览
@@ -13,7 +13,8 @@
- 技术栈:`TypeScript + React (Vite) + Go`
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
- Base URL:`http://localhost:5173`
- 任务板:`.pouch/ack/tasks.yaml`
- 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`
- 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`
- 回归目录:`.pouch/ack/regression.yaml`
@@ -57,7 +58,8 @@
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
@@ -101,7 +103,7 @@ path/args。
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
- 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
+1 -1
View File
@@ -4,7 +4,7 @@
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `pouch init ack --project <project-root>`。
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
+16 -13
View File
@@ -2,7 +2,8 @@
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨
原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层
materialize 成任务视图);已经验证且可跨
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
Developer 只实现。
@@ -15,12 +16,12 @@ Developer 只实现。
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|----------|------|-----------|----------|
| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
| `prepare(task)` | Coordinator 把任务和验收信号写进 `task-records/`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把任务结果写回 `tasks.yaml`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把任务结果追加到 `task-records/`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
@@ -30,14 +31,14 @@ Developer 只实现。
```text
Coordinator 发现或读取 open 任务
-> prepare:写/补全 tasks.yaml 验收信号
-> prepare:写/补全 task-records/ 中的任务验收信号
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
-> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration
-> 运行 launcher plan 并审阅 launchFingerprint
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md)
-> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md)
-> dispatch 给 Developer(--to <worker handle>)
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
@@ -133,7 +134,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把
- 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
@@ -173,9 +174,10 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
| 要保持基线分支干净 | 新 worktree(feature 分支)|
| 小改动、追求快 | 当前 worktree |
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
**项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、
副本(通常在基线/协调所在 worktree)。任务执行状态追加到
`.pouch/ack/task-records/<recordId>.yaml`;Coordinator 单写任务记录、知识、回归目录、
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
@@ -190,20 +192,20 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。
1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
手动发给 Developer(另一个会话/终端/人)。
3. Developer 完成后按 worker_done 模板回报。
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
不派 Developer、不消耗产品轮次。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
@@ -233,7 +235,7 @@ actual:
snapshot evidence:
```
Test 只回传证据,不写 `tasks.yaml` 或 `knowledge.yaml`;由 Coordinator 落盘。
Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
`not_applicable` 及证据。
@@ -277,7 +279,8 @@ worktreePath:
## 结果回写
Coordinator 回写 `tasks.yaml` 时按状态填写(字段结构见 `templates/tasks.schema.json`):
Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见
`templates/tasks.schema.json`):
```yaml
# 通过(Test 复测 + Coordinator 终检)
+2 -2
View File
@@ -3,7 +3,7 @@
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
`.pouch/ack/tasks.yaml.deliveryRuns`。
任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。
## 1. 配置与授权不是一回事
@@ -78,7 +78,7 @@ intents:
口头「发版」不能代替 stable/production 的 `approval` 步骤。
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填
- 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
`verified` 任务。
+2 -2
View File
@@ -33,7 +33,7 @@
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
4. 把任务写进 .pouch/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
@@ -41,7 +41,7 @@
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
fingerprint 创建 fresh worker;
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
dispatch 测试独立复测 → 你读证据终检 → 追加 task-records;
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
记录、恢复并告诉我下一步,不占产品复验轮次。
7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
+6 -5
View File
@@ -2,7 +2,8 @@
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
所有结论仍以 `tasks.yaml` 为准,Orca 消息不是最终记录。
所有结论仍以读取层 materialize 的任务视图为准,底层最终记录追加在
`task-records/`;Orca 消息不是最终记录。
---
@@ -96,7 +97,7 @@ Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的
标准输出是单一 JSON。成功后:
1. 校验返回 receipt 的 `receiptHash`;
2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`;
2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`;
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
@@ -129,7 +130,7 @@ Policy:
- Each issue can have at most 3 valid product-retest rounds.
- Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round.
- If still failing after 3 valid product rounds, record as leftover and continue next issue.
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml and knowledge.yaml.
- worker_done and retest reports are not final completion; only Coordinator writes task-records/ and knowledge.yaml.
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
EOF
)" --json
@@ -324,7 +325,7 @@ orca orchestration send \
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由
Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`:
Coordinator 按 `closed-loop.md` 做终检并追加任务记录:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
@@ -334,7 +335,7 @@ python3 <ack-skill-dir>/scripts/reclaim_workers.py \
只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并追加任务记录;
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
+2 -2
View File
@@ -10,10 +10,10 @@
项目状态是 `.pouch/ack/regression.yaml`。任务板用
`project.regressionFile: .pouch/ack/regression.yaml` 声明,运行证据写在
`tasks.yaml.regressionRuns`。新项目初始化会生成空目录;旧项目没有该文件时,
任务记录的 `regressionRuns`(读取时 materialize 到任务视图)。新项目初始化会生成空目录;旧项目没有该文件时,
「运行回归」先停下来,用户授权后再从模板补齐。
只有 Coordinator 写 `regression.yaml`。Test 在复测报告里提名
只有 Coordinator 写 `regression.yaml` 和任务记录。Test 在复测报告里提名
`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
`.pouch/ack/`。
@@ -12,12 +12,12 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
| Coordinator (PM) | 需求拆解、定验收信号、单写 `task-records/` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有追加到 `task-records/` 才算数。
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
@@ -108,7 +108,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只保存稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
@@ -143,7 +144,7 @@ launch / service / data / browser / tooling environment failure
-> 环境恢复后 retesting
```
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同):
| 状态 | 依据来源 | 含义 |
|------|----------|------|
@@ -179,11 +180,11 @@ planned -> skipped
## 项目状态写入约定(并发安全)
`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识
事实源,`regression.yaml` 是黑盒回归用例事实源,`delivery.yaml` 是项目交付能力
事实源。为避免多 Agent 并发写冲突:
`task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置,
`knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源,
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
- **只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`**。
- **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。
Test 与 Developer 对它们都是只读的。
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
+5 -14
View File
@@ -25,6 +25,7 @@ from pathlib import Path
from typing import Any
from approval_payload import approval_payload_hash, review_items
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
from validate_tasks import validate_builtin as validate_task_board
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
@@ -180,21 +181,11 @@ def cli_environment() -> dict[str, str]:
def load_board(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".json":
value = load_json_unique(text)
else:
try:
import yaml # type: ignore
value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml))
except ImportError:
value = load_yaml_subset(text)
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
value = parse_document(path)
if is_task_store_manifest(value):
value = materialize_board(value, path)
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError, TaskStoreError) as exc:
raise IntakeError(f"cannot read task board: {exc}") from exc
except Exception as exc: # PyYAML errors are intentionally not exposed verbatim.
raise IntakeError("cannot parse task board") from exc
if not isinstance(value, dict):
raise IntakeError("task board must be an object")
return value
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Migrate a legacy ACK tasks.yaml into immutable task records.
The default is a read-only dry run. ``--apply`` writes a sibling
``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces
the task board with a small manifest that points at ``task-records/``.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Any
from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
try:
temporary.write_text(content, encoding="utf-8")
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def _record_id(task_id: str) -> str:
return f"EV-{task_id}-initial"
def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]:
value: dict[str, Any] = {
"recordVersion": STORE_VERSION,
"recordId": _record_id(task_id),
"taskId": task_id,
"parents": [],
"kind": "task.snapshot",
}
if task is not None:
value["task"] = task
return value
def build_records(board: dict[str, Any]) -> list[dict[str, Any]]:
tasks = board.get("tasks")
if not isinstance(tasks, list):
raise TaskStoreError("legacy tasks 必须是列表")
records: list[dict[str, Any]] = []
task_ids: set[str] = set()
for task in tasks:
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
raise TaskStoreError("每条 legacy task 必须包含字符串 id")
task_id = task["id"]
if task_id in task_ids:
raise TaskStoreError(f"task id 重复: {task_id}")
task_ids.add(task_id)
records.append(_record(task_id, dict(task)))
receipts = board.get("workerReceipts") or []
if not isinstance(receipts, list):
raise TaskStoreError("workerReceipts 必须是列表")
by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids}
global_receipts: list[dict[str, Any]] = []
for receipt in receipts:
if not isinstance(receipt, dict):
raise TaskStoreError("workerReceipts 每项必须是对象")
created_for = receipt.get("createdFor")
task_id = created_for.get("taskId") if isinstance(created_for, dict) else None
if isinstance(task_id, str) and task_id in by_task:
by_task[task_id].append(receipt)
else:
global_receipts.append(receipt)
for record in records:
task_id = record["taskId"]
if by_task[task_id]:
record["workerReceipts"] = by_task[task_id]
global_record = _record(GLOBAL_TASK_ID)
if global_receipts:
global_record["workerReceipts"] = global_receipts
for field in ("deliveryRuns", "regressionRuns"):
value = board.get(field)
if value is not None:
if not isinstance(value, list):
raise TaskStoreError(f"{field} 必须是列表")
global_record[field] = value
if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")):
records.append(global_record)
return records
def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]:
board = parse_document(path)
if "taskStore" in board:
raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移")
records = build_records(board)
record_directory = directory or "task-records"
relative = Path(record_directory)
if relative.is_absolute() or ".." in relative.parts:
raise TaskStoreError("records directory 必须是任务板目录下的相对路径")
output_dir = path.parent / relative
backup = path.with_name(f"{path.stem}.legacy{path.suffix}")
if apply and backup.exists():
raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}")
manifest = dict(board)
for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"):
manifest.pop(field, None)
manifest["tasks"] = []
manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory}
if apply:
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, backup)
output_dir.mkdir(parents=True, exist_ok=True)
for record in records:
record_path = output_dir / f"{record['recordId']}.yaml"
if record_path.exists():
raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}")
_atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n")
_atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
return {
"mode": "apply" if apply else "dry-run",
"tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]),
"records": len(records),
"recordsDirectory": str(output_dir),
"backup": str(backup),
"taskStore": manifest["taskStore"],
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
parser.add_argument("--apply", action="store_true", help="写入记录并替换任务板")
parser.add_argument("--records-dir", help="任务记录目录,默认 task-records")
args = parser.parse_args(argv)
try:
result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir)
except (OSError, TaskStoreError) as exc:
print(f"迁移失败: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Append one immutable ACK task record.
The input task is a complete snapshot. The command never edits an existing
record; it creates a new file with exclusive creation and lets the materializer
report multiple heads when two workspaces branched from the same parent.
"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from task_store import (
RECORD_ID_RE,
STORE_VERSION,
TASK_ID_RE,
TaskStoreError,
materialize_board,
parse_document,
)
def _read_json_mapping(path: Path, label: str) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise TaskStoreError(f"{label}读取失败: {path}") from exc
if not isinstance(value, dict):
raise TaskStoreError(f"{label}必须是对象")
return value
def _read_json_list(path: Path, label: str) -> list[dict[str, Any]]:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise TaskStoreError(f"{label}读取失败: {path}") from exc
if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
raise TaskStoreError(f"{label}必须是对象列表")
return raw
def _auto_record_id(task_id: str) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"EV-{task_id}-{stamp}-{secrets.token_hex(4)}"
def append_record(
board_path: Path,
task: dict[str, Any],
*,
task_id: str,
record_id: str | None,
parents: list[str],
kind: str,
worker_receipts: list[dict[str, Any]] | None = None,
delivery_runs: list[dict[str, Any]] | None = None,
regression_runs: list[dict[str, Any]] | None = None,
) -> Path:
manifest = parse_document(board_path)
if not isinstance(manifest.get("taskStore"), dict):
raise TaskStoreError("任务板没有启用 taskStore;先运行 migrate_tasks.py --apply")
if TASK_ID_RE.fullmatch(task_id) is None:
raise TaskStoreError("taskId 格式无效")
if task.get("id") != task_id:
raise TaskStoreError("输入 task.id 必须与 --task-id 相同")
if not isinstance(kind, str) or not kind.strip():
raise TaskStoreError("kind 必须是非空字符串")
chosen_id = record_id or _auto_record_id(task_id)
if RECORD_ID_RE.fullmatch(chosen_id) is None:
raise TaskStoreError("recordId 格式无效")
if any(not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None for parent in parents):
raise TaskStoreError("parents 必须是 recordId 字符串列表")
# Materialize first so malformed existing records or unresolved heads stop
# the next write instead of compounding an already ambiguous store.
materialized = materialize_board(manifest, board_path)
existing_errors = materialized.get("taskStoreErrors")
if isinstance(existing_errors, list) and existing_errors:
raise TaskStoreError("现有 taskStore 已有冲突,先解决: " + "; ".join(map(str, existing_errors)))
directory = board_path.parent / str(manifest["taskStore"].get("directory", "task-records"))
directory.mkdir(parents=True, exist_ok=True)
output = directory / f"{chosen_id}.yaml"
record: dict[str, Any] = {
"recordVersion": STORE_VERSION,
"recordId": chosen_id,
"taskId": task_id,
"parents": parents,
"kind": kind,
"task": task,
}
for field, value in (
("workerReceipts", worker_receipts),
("deliveryRuns", delivery_runs),
("regressionRuns", regression_runs),
):
if value:
record[field] = value
content = json.dumps(record, ensure_ascii=False, indent=2) + "\n"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
descriptor: int | None = None
try:
descriptor = os.open(output, flags, 0o644)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
descriptor = None
handle.write(content)
except FileExistsError as exc:
raise TaskStoreError(f"记录已存在,拒绝覆盖: {output}") from exc
finally:
if descriptor is not None:
os.close(descriptor)
return output
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
parser.add_argument("--task-id", required=True)
parser.add_argument("--task-json", type=Path, required=True, help="完整 task 快照 JSON")
parser.add_argument("--record-id")
parser.add_argument("--parent", action="append", default=[])
parser.add_argument("--kind", default="task.snapshot")
parser.add_argument("--worker-receipts-json", type=Path)
parser.add_argument("--delivery-runs-json", type=Path)
parser.add_argument("--regression-runs-json", type=Path)
args = parser.parse_args(argv)
try:
task = _read_json_mapping(args.task_json, "task")
output = append_record(
Path(args.tasks), task, task_id=args.task_id,
record_id=args.record_id, parents=args.parent, kind=args.kind,
worker_receipts=(
_read_json_list(args.worker_receipts_json, "worker receipt")
if args.worker_receipts_json else None
),
delivery_runs=(
_read_json_list(args.delivery_runs_json, "delivery run")
if args.delivery_runs_json else None
),
regression_runs=(
_read_json_list(args.regression_runs_json, "regression run")
if args.regression_runs_json else None
),
)
except (OSError, TaskStoreError) as exc:
print(f"追加任务记录失败: {exc}", file=sys.stderr)
return 1
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Merge-friendly ACK task records.
``tasks.yaml`` remains the small project manifest. When it contains a
``taskStore`` mapping, task state is materialized from immutable record files
under the configured directory. Legacy boards without that mapping keep
their original single-file behavior.
Records are complete task snapshots rather than patches. A later record
names its parent record(s), so a concurrent edit produces multiple heads and
is reported as a semantic merge conflict instead of being silently resolved
by file order or timestamps.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
STORE_VERSION = 1
GLOBAL_TASK_ID = "__project__"
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
RECORD_SUFFIXES = {".yaml", ".yml", ".json"}
class TaskStoreError(ValueError):
"""A malformed task store or record."""
def parse_document(path: Path) -> dict[str, Any]:
"""Parse one YAML/JSON mapping without importing the validator."""
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise TaskStoreError(f"读取失败: {path}: {exc}") from exc
try:
if path.suffix.lower() == ".json":
value = load_json_unique(content)
else:
try:
import yaml # type: ignore
except ImportError:
value = load_yaml_subset(content)
else:
value = yaml.load(
content,
Loader=make_unique_pyyaml_loader(yaml),
)
except (json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
raise TaskStoreError(f"解析失败: {path}: {exc}") from exc
except Exception as exc: # PyYAML errors are intentionally bounded.
raise TaskStoreError(f"解析失败: {path}") from exc
if not isinstance(value, dict):
raise TaskStoreError(f"{path}: 顶层必须是对象(mapping)")
return value
def _record_directory(manifest: dict[str, Any], manifest_path: Path) -> Path:
config = manifest.get("taskStore")
if not isinstance(config, dict):
raise TaskStoreError("taskStore 必须是对象")
version = config.get("version", STORE_VERSION)
if version != STORE_VERSION:
raise TaskStoreError(f"不支持 taskStore.version={version!r}")
directory = config.get("directory", "task-records")
if not isinstance(directory, str) or not directory.strip():
raise TaskStoreError("taskStore.directory 必须是非空相对路径")
relative = Path(directory)
if relative.is_absolute() or ".." in relative.parts:
raise TaskStoreError("taskStore.directory 必须位于任务板目录下")
return manifest_path.parent / relative
def _record_paths(directory: Path) -> list[Path]:
if not directory.exists():
return []
if directory.is_symlink() or not directory.is_dir():
raise TaskStoreError(f"taskStore.directory 不是普通目录: {directory}")
paths: list[Path] = []
for path in sorted(directory.iterdir(), key=lambda item: item.name):
if path.name.startswith("."):
continue
if path.is_symlink() or not path.is_file():
raise TaskStoreError(f"任务记录必须是普通文件: {path}")
if path.suffix.lower() in RECORD_SUFFIXES:
paths.append(path)
return paths
def _unique_collection(
records: list[dict[str, Any]],
field: str,
errors: list[str],
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
by_id: dict[str, dict[str, Any]] = {}
for record in records:
values = record.get(field)
if values is None:
continue
if not isinstance(values, list):
errors.append(f"{field} 必须是列表")
continue
for index, item in enumerate(values):
if not isinstance(item, dict):
errors.append(f"{field}[{index}] 必须是对象")
continue
item_id = item.get("id")
if not isinstance(item_id, str) or not item_id:
errors.append(f"{field}[{index}].id 必须是非空字符串")
continue
previous = by_id.get(item_id)
if previous is not None and previous != item:
errors.append(f"{field} 出现相同 id 的不同记录: {item_id}")
continue
if previous is None:
by_id[item_id] = item
result.append(item)
return result
def materialize_board(manifest: dict[str, Any], manifest_path: Path) -> dict[str, Any]:
"""Return a board-shaped view from a manifest and immutable records."""
directory = _record_directory(manifest, manifest_path)
paths = _record_paths(directory)
# This permits a staged rollout: a freshly initialized board has an empty
# store and the legacy empty ``tasks: []`` field until its first record.
if not paths:
return manifest
errors: list[str] = []
records: list[dict[str, Any]] = []
by_record_id: dict[str, dict[str, Any]] = {}
for path in paths:
record = parse_document(path)
where = str(path.relative_to(manifest_path.parent))
version = record.get("recordVersion", STORE_VERSION)
record_id = record.get("recordId")
task_id = record.get("taskId")
parents = record.get("parents", [])
if version != STORE_VERSION:
errors.append(f"{where}: 不支持 recordVersion={version!r}")
if not isinstance(record_id, str) or RECORD_ID_RE.fullmatch(record_id) is None:
errors.append(f"{where}: recordId 格式无效")
elif record_id in by_record_id:
errors.append(f"{where}: recordId 重复: {record_id}")
if not isinstance(task_id, str) or (
task_id != GLOBAL_TASK_ID and TASK_ID_RE.fullmatch(task_id) is None
):
errors.append(f"{where}: taskId 格式无效")
if not isinstance(parents, list) or any(
not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None
for parent in parents
):
errors.append(f"{where}: parents 必须是 recordId 字符串列表")
task = record.get("task")
if task_id != GLOBAL_TASK_ID and not isinstance(task, dict):
errors.append(f"{where}: 非全局记录必须包含 task 对象")
if isinstance(record_id, str) and record_id not in by_record_id:
by_record_id[record_id] = record
records.append(record)
for record in records:
record_id = record.get("recordId")
task_id = record.get("taskId")
if not isinstance(record_id, str) or not isinstance(task_id, str):
continue
for parent in record.get("parents", []):
if parent not in by_record_id:
errors.append(f"{record_id}: 引用了不存在的 parent: {parent}")
elif by_record_id[parent].get("taskId") != task_id:
errors.append(f"{record_id}: parent 必须属于同一个 taskId")
task_records: dict[str, list[dict[str, Any]]] = {}
for record in records:
task_id = record.get("taskId")
if isinstance(task_id, str) and task_id != GLOBAL_TASK_ID:
task_records.setdefault(task_id, []).append(record)
tasks: list[dict[str, Any]] = []
for task_id in sorted(task_records):
items = task_records[task_id]
referenced = {
parent
for item in items
for parent in item.get("parents", [])
if isinstance(parent, str)
}
heads = [
item for item in items
if isinstance(item.get("recordId"), str)
and item["recordId"] not in referenced
]
if len(heads) != 1:
errors.append(
f"task {task_id}: 发现 {len(heads)} 个未合并 head,必须创建同时引用全部 head 的合并记录"
)
continue
task = heads[0].get("task")
if isinstance(task, dict):
tasks.append(task)
result = dict(manifest)
result["tasks"] = tasks
result["workerReceipts"] = _unique_collection(records, "workerReceipts", errors)
for field in ("deliveryRuns", "regressionRuns"):
if field in manifest or any(field in record for record in records):
result[field] = _unique_collection(records, field, errors)
result["summary"] = {
"open": [task["id"] for task in tasks if task.get("status") == "open"],
"verified": [task["id"] for task in tasks if task.get("status") == "verified"],
"failedRetest": [task["id"] for task in tasks if task.get("status") == "failed_retest"],
"leftovers": [task["id"] for task in tasks if task.get("status") == "leftover"],
}
if errors:
result["taskStoreErrors"] = errors
else:
result.pop("taskStoreErrors", None)
return result
def is_task_store_manifest(value: object) -> bool:
return isinstance(value, dict) and "taskStore" in value
__all__ = [
"GLOBAL_TASK_ID",
"STORE_VERSION",
"TaskStoreError",
"is_task_store_manifest",
"materialize_board",
"parse_document",
]
+5 -37
View File
@@ -22,15 +22,7 @@ import sys
from pathlib import Path, PurePosixPath
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
@@ -162,36 +154,12 @@ def _reject_unknown(
def _load_document(path: Path, label: str) -> dict[str, Any]:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
if path.suffix.lower() == ".json":
try:
data = load_json_unique(content)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
raise SystemExit(1)
return data
+5 -37
View File
@@ -21,15 +21,7 @@ import sys
from pathlib import Path
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
@@ -75,36 +67,12 @@ def _reject_unknown(
def load_yaml(path: Path, label: str) -> dict[str, Any]:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
if path.suffix.lower() == ".json":
try:
data = load_json_unique(content)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
raise SystemExit(1)
return data
+10 -37
View File
@@ -25,13 +25,7 @@ import sys
from pathlib import Path
from approval_payload import approval_payload_hash
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
from worker_profiles import validate_orchestration
from worker_profiles import validate_routing_document
@@ -243,38 +237,12 @@ def _nonempty_string(value: object) -> bool:
def load_document(path: Path) -> dict:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"任务板读取失败: {exc}\n")
raise SystemExit(1)
if path.suffix.lower() == ".json":
try:
data = load_json_unique(content)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
sys.stderr.write(f"JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(
content,
Loader=make_unique_pyyaml_loader(yaml),
)
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write("任务板顶层必须是对象(mapping)\n")
raise SystemExit(1)
return data
@@ -901,6 +869,11 @@ def validate_builtin(
orchestration_override: dict | None = None,
) -> list[str]:
errors: list[str] = []
store_errors = data.get("taskStoreErrors")
if isinstance(store_errors, list):
for error in store_errors:
if isinstance(error, str) and error.strip():
errors.append(f"taskStore: {error}")
project_intake_workflow = "read-only-v1"
def validate_string_fields(
+6 -4
View File
@@ -8,7 +8,7 @@
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、默认关闭的
> `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖,
> 不复制或链接 Skill。
@@ -18,7 +18,8 @@
- 技术栈:`<tech_stack>`
- 运行命令:`<run_command>`
- Base URL:`<base_url>`
- 任务板:`.pouch/ack/tasks.yaml`
- 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`(追加式)
- 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
- 回归目录:`.pouch/ack/regression.yaml`
@@ -68,7 +69,8 @@ receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml`
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
@@ -117,7 +119,7 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`;
- 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`;
Developer 与 Test 都只读,通过消息回报。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。
+19
View File
@@ -115,6 +115,25 @@
"statusReference": {
"type": "object"
},
"taskStore": {
"type": "object",
"required": [
"version",
"directory"
],
"properties": {
"version": {
"type": "integer",
"const": 1
},
"directory": {
"type": "string",
"minLength": 1,
"description": "相对 tasks.yaml 的不可变任务记录目录"
}
},
"additionalProperties": true
},
"workerReceipts": {
"type": "array",
"items": {
+4
View File
@@ -3,6 +3,10 @@ version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
source: "Coordinator (PM) Agent"
ackVersion: "<接入时的 ack skill 版本>"
# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。
taskStore:
version: 1
directory: "task-records"
project:
name: "<project_name>"
baseUrl: "<base_url>"
+1 -1
View File
@@ -47,6 +47,6 @@
## 如何判断完成
- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。最近一次运行的结果在同一张表的 `last_result` / `last_executed` 列。
- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。每次运行的结果保存在独立的 `.pouch/tester/runs/<run-id>.*` 文件中。
- **维护**:目标 id 能 `查看` 到,且检查通过。
- **运行**:有本次 `runs/*.yaml` 快照和同名 `.csv` 成绩单(`executed` 列区分已跑 / 未跑);没填 HOST 时会停,而不是编造失败。
+1 -1
View File
@@ -71,7 +71,7 @@ python3 <skill-dir>/scripts/catalog.py list --root <project-root>
python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root>
```
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。有运行快照时,这份 CSV 末尾有 `last_result` / `last_executed`(`yes` 已跑,`no` 未跑——含 `skipped` 与 `not-run`,空=未进入最近一次运行)。
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。该 CSV 只包含用例定义;运行结果只写入独立的 `.pouch/tester/runs/<run-id>.yaml`,不会回写清单。
## 查看
+1 -1
View File
@@ -35,5 +35,5 @@ python3 <skill-dir>/scripts/catalog.py next-id --root <root> --category <CAT>
- 为了能在默认风险下跑而把 `risk` 改低
- 一次塞进无关的一批用例
- 手改 `runs/` 冒充执行记录
- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;`last_*` 列来自最近一次 `runs/*.yaml`)
- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成;运行结果只在对应 `runs/<run-id>.*` 中保存)
- 手改 `runs/*.csv`(必须从对应 yaml 用 `generate_run_csv.py` 生成)
+3 -2
View File
@@ -83,13 +83,14 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。
写入 `.pouch/tester/runs/<UTC时间>-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。立刻用同一快照生成 CSV,不要手改:
写入 `.pouch/tester/runs/<UTC时间>-<范围>-<随机后缀>.yaml`,可用
`generate_run_csv.new_run_id(<范围>)` 生成同名 `runId`。记录包含唯一 `runId`、筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。记录文件只创建一次,不覆盖已有文件。立刻用同一快照生成 CSV,不要手改:
```bash
python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
```
这会写出 `.pouch/tester/runs/<UTC时间>-<范围>.csv`(`executed=yes|no`),并刷新 `cases.csv` 的 `last_result` / `last_executed` 列。对话里两个路径都报。
这会写出与快照同名的 `.pouch/tester/runs/<run-id>.csv`(`executed=yes|no`),不会修改 `cases.csv`。对话里两个路径都报。
对话报告:
+6 -2
View File
@@ -9,8 +9,8 @@ catalog.yaml 分类、风险阶梯、session、变量
local.yaml.example HOST 与授权开关模板(默认 127.0.0.1)
local.yaml 本机覆盖,不提交
cases/<category>.yaml 一个分类一个文件(执行源)
cases.csv 给人看的扁平清单;generate_cases_csv.py 读 cases/*.yaml,并拼最近一次运行的 last_* 列
runs/*.yaml 每次运行快照(执行源)
cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果
runs/<run-id>.yaml 每次运行快照(执行源);run-id 必须唯一
runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对应 yaml 生成
```
@@ -33,6 +33,10 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
`catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
运行记录使用 UTC 时间加随机后缀命名,例如
`20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。
不要使用固定的 `latest.yaml` 或覆盖已有运行文件。
## 一条用例
写在对应分类文件的 `cases:` 列表里。
+5 -33
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。"""
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。
运行结果属于 runs/ 下的追加式记录,不回写本清单,避免多个工作区共同
刷新运行状态造成合并冲突。
"""
from __future__ import annotations
@@ -32,11 +36,6 @@ FIELDS = (
"how",
"expected",
"notes",
"last_run",
"last_result",
"last_executed",
"last_evidence",
"last_notes",
)
@@ -148,28 +147,8 @@ def sort_cases(root: Path, cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
)
def last_run_lookup(root: Path) -> tuple[str, dict[str, dict[str, Any]]]:
from generate_run_csv import latest_run_yaml, load_run, results_by_id
run_path = latest_run_yaml(root)
if run_path is None:
return "", {}
try:
run = load_run(run_path)
except SystemExit:
return "", {}
try:
rel = str(run_path.relative_to(root))
except ValueError:
rel = str(run_path)
return rel, results_by_id(run)
def export_cases_csv(root: Path, out: Path | None = None) -> Path:
from generate_run_csv import executed_cell
cases = sort_cases(root, load_cases(root))
last_run, last_by_id = last_run_lookup(root)
path = out or (tester_dir(root) / "cases.csv")
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as handle:
@@ -177,8 +156,6 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
writer.writeheader()
for case in cases:
cid = str(case.get("id") or "")
last = last_by_id.get(cid) or {}
last_result = str(last.get("result") or "") if last else ""
writer.writerow(
{
"category": case.get("category") or "",
@@ -197,11 +174,6 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
"how": how_brief(case.get("how")),
"expected": one_line(case.get("expected")),
"notes": one_line(case.get("notes")),
"last_run": last_run if last else "",
"last_result": last_result,
"last_executed": executed_cell(last_result) if last else "",
"last_evidence": one_line(last.get("evidence")) if last else "",
"last_notes": one_line(last.get("notes")) if last else "",
}
)
return path
+24 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import Counter
from pathlib import Path
@@ -19,6 +20,7 @@ except ImportError: # pragma: no cover
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
EXECUTED_NO = frozenset({"skipped", "not-run"})
FIELDS = (
@@ -56,6 +58,22 @@ def latest_run_yaml(root: Path) -> Path | None:
return files[-1] if files else None
def new_run_id(scope: str, suffix: str | None = None) -> str:
"""Return a collision-resistant filename/id for a new run snapshot."""
from datetime import datetime, timezone
from secrets import token_hex
clean_scope = re.sub(r"[^A-Za-z0-9._-]+", "-", scope).strip("-") or "run"
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
clean_suffix = re.sub(r"[^A-Za-z0-9._-]+", "-", suffix or "").strip("-")
random_part = token_hex(3)
parts = [stamp, clean_scope]
if clean_suffix:
parts.append(clean_suffix)
parts.append(random_part)
return "-".join(parts)
def load_run(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
@@ -63,6 +81,11 @@ def load_run(path: Path) -> dict[str, Any]:
results = data.get("results")
if not isinstance(results, list):
raise SystemExit(f"{path} 缺少 results 列表")
run_id = data.get("runId")
if run_id is not None and (
not isinstance(run_id, str) or RUN_ID_RE.fullmatch(run_id) is None
):
raise SystemExit(f"{path} 的 runId 格式无效")
return data
@@ -88,7 +111,6 @@ def export_run_csv(
root: Path,
run_path: Path | None = None,
out: Path | None = None,
refresh_cases: bool = True,
) -> Path:
path = run_path or latest_run_yaml(root)
if path is None:
@@ -122,10 +144,6 @@ def export_run_csv(
"maps_to": case.get("maps_to") or "",
}
)
if refresh_cases:
from generate_cases_csv import export_cases_csv
export_cases_csv(root)
return dest
@@ -137,7 +155,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--no-refresh-cases",
action="store_true",
help="不要顺便刷新 cases.csv 的 last_* 列",
help="兼容旧命令;cases.csv 已不会被运行结果刷新",
)
args = parser.parse_args(argv)
root = resolve_root(args.root)
@@ -147,7 +165,6 @@ def main(argv: list[str] | None = None) -> int:
root,
run_path=run_path,
out=out,
refresh_cases=not args.no_refresh_cases,
)
counts: Counter[str] = Counter()
with path.open(encoding="utf-8-sig", newline="") as handle: