feat(ack): shard task records for merge-safe workspaces
This commit is contained in:
+10
-2
@@ -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
@@ -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 初始化:完成 | 部分完成 | 阻塞
|
||||
|
||||
@@ -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 回报
|
||||
|
||||
@@ -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` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||
|
||||
@@ -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 终检)
|
||||
|
||||
@@ -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` 任务。
|
||||
|
||||
@@ -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「运行回归」给出新增/更新/退役/无回归,确认后写入
|
||||
|
||||
@@ -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`,不占复验轮次。
|
||||
|
||||
@@ -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 保存在
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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` 不派发,知识库不全量注入。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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 时会停,而不是编造失败。
|
||||
|
||||
@@ -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`,不会回写清单。
|
||||
|
||||
## 查看
|
||||
|
||||
|
||||
@@ -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` 生成)
|
||||
|
||||
@@ -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`。对话里两个路径都报。
|
||||
|
||||
对话报告:
|
||||
|
||||
|
||||
@@ -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:` 列表里。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user