@michaelxwb/code-flow 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelxwb/code-flow",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "license": "UNLICENSED",
5
5
  "bin": {
6
6
  "code-flow": "src/cli.js"
@@ -36,6 +36,8 @@
36
36
 
37
37
  - 需求目录必须存在 `spec-context.yml`;执行 `python3 .code-flow/scripts/cf_spec_context.py refresh --task-dir <需求目录> --root "$PWD" --json`
38
38
  - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage code --json`;required Rule 的 stale/pending/conflict/unverified 任一存在即 FAIL
39
+ - 执行 review 终验:`python3 .code-flow/scripts/cf_task_workflow.py verify-e2e --task-dir <需求目录> --root "$PWD" --json`(聚合需求目录全部 task context 的 `stage: review` verifier + acceptance E2E,按 spec/rule 去重执行一次并写回证据);`decision=pass` 才继续,失败保持阻断
40
+ - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage review --json`;required Rule 存在 unverified review 证据即 FAIL(先完成 review 终验)
39
41
  - 若 `.code-flow/.active-task.json` 仍指向本需求,先执行 `python3 .code-flow/scripts/cf_task_workflow.py finish --root "$PWD" --task-dir "<需求目录>" --task TASK-001 --json`,通过后再 verify-e2e;不得自报 gate_passed 或通过归档绕过 active 状态
40
42
  - Context 与 Evidence 随需求目录一并归档,`_session` 仅是可重建投影,不是事实源
41
43
 
@@ -209,6 +209,23 @@ python3 .code-flow/scripts/cf_task_parallel.py prepare --root "$PWD" \
209
209
  4. 完成时在 worktree 内提交全部改动(含任务文件 Checklist/Evidence/Status 更新),提交信息 `cf-task(<TASK-ID>): <标题>`。
210
210
  5. 返回摘要:TASK-ID、Status、验收命令及结果、提交 SHA、遗留问题。
211
211
 
212
+ 标准 worker prompt 模板(替换占位符后派发):
213
+
214
+ ```
215
+ 你在隔离 worktree 中执行 <TASK-ID>:<worktree 绝对路径>。
216
+ 1) cd 到 worktree;先读 Spec Session(.code-flow/specs/_session/task-<name>.md)、任务文件的当前 TASK 段落与 design 来源章节;
217
+ 2) cf_spec_context.py start → 写验收测试记录 RED → 实现 → GREEN;
218
+ 3) 运行 cf_task_workflow.py finish --root "$PWD" --task <TASK-ID> --json;decision=pass 后【再】提交全部改动(含 finish 回写的 Evidence/状态):git add -A && git commit -m "cf-task(<TASK-ID>): <标题>";
219
+ 4) 返回摘要:TASK-ID、Status、验收命令与结果、commit SHA、遗留问题。
220
+ ```
221
+
222
+ > 提交必须在 finish 之后:finish 通过后会回写 Evidence/状态;collect 默认会自动提交这些回写,但显式提交更清晰。
223
+
224
+ 子 agent 中断(取消/超时)时不要重新 prepare:检查该 worktree 的 `git status`、`git log <base>..HEAD` 与 marker——
225
+ - 无改动且无 marker:直接接管,按单任务模式继续;
226
+ - 有未提交改动或已有提交但未 done:在 worktree 内接管收尾(补实现/测试并 finish),再走 4.3 collect;
227
+ - 已 done 且 marker 已清理:直接进入 4.3。
228
+
212
229
  平台不支持子 agent 时,不建 worktree,直接走 4.6 串行回退。
213
230
 
214
231
  #### 4.3 收集与校验
@@ -218,6 +235,7 @@ python3 .code-flow/scripts/cf_task_parallel.py collect --root "$PWD" --run-id <r
218
235
  ```
219
236
 
220
237
  - 每个任务必须 `ok: true`(改动已提交、Status 为 done/verified、marker 已清理、有提交)。
238
+ - `collect` 在任务 done/verified 且 marker 已清理后,会自动提交 finish 回写的 Evidence/状态(`--no-commit` 关闭并回到严格模式);finish 之外的未提交改动仍应人工确认。
221
239
  - 任一任务失败:停止本批次,不合并;保留 worktree 并列出失败原因。修复后重跑 collect;确认放弃时执行 4.5 cleanup。
222
240
 
223
241
  #### 4.4 回并主分支
@@ -240,6 +258,7 @@ git merge --no-ff -m "merge cf-task <TASK-ID>" <branch>
240
258
  ```
241
259
 
242
260
  - 仍出现冲突(主区期间有新合并):按双方意图解决并重跑双方 functional 验收;通过后提交 merge。
261
+ - 状态文件冲突(`spec-context.yml` / 任务 md 的覆盖状态列 / `.acceptance-manifest.json`)按确定性优先级解决:覆盖状态列取并集(两边各自 verified 的行保留 verified);`spec-context.yml` 取已包含全量规则证据的一侧;manifest 多数情况按行自动合并,冲突时以任务文件为准重建后重跑 functional 场景。解决后必须 `cf_spec_context.py refresh` 收敛 hash,并重跑合并双方的 functional 验收与 `cf_spec_gate --stage code --json`,通过后提交 merge。
243
262
  - 合并后验收失败:记录合并前 HEAD 并 `git reset --hard <合并前HEAD>`(分支与 worktree 原样保留),回到 4.2 让对应子 agent 修复后重新 collect / rebase / 合并。
244
263
 
245
264
  3. 全部任务合并完成后进入 4.5 清理。
@@ -9,7 +9,12 @@ globs: []
9
9
 
10
10
  ## 使用场景
11
11
 
12
- E2E 测试依赖外部环境(数据库、API、浏览器等),在编码阶段默认跳过(状态 `e2e_deferred`)。当所有子任务的 functional 测试通过后,使用此命令统一执行 E2E 验收。
12
+ 本命令是**需求级终验**入口,在所有子任务的 functional 测试通过后统一执行:
13
+
14
+ 1. **review 层 verifier**:绑定规范中 `stage: review` 的 required verifier(e2e/acceptance/构建等重型验证)。遍历需求目录全部 task context,按 spec/rule 去重执行一次,证据写回全部相关 context;输出 `executed` / `reused` / `failed` 计数。
15
+ 2. **E2E 验收场景**:依赖外部环境(数据库、API、浏览器等)、编码阶段标记 `e2e_deferred` 的场景。
16
+
17
+ 失败不写 verified、不反转已 done 的任务状态;归档前必须 `decision=pass`。未声明 review verifier 且无 E2E 场景时为空操作(`reason=nothing_to_verify`)。
13
18
 
14
19
  ## 调用方式
15
20
 
@@ -37,6 +37,8 @@ description: Archive a completed task file after completeness, correctness, trac
37
37
 
38
38
  - 需求目录必须存在 `spec-context.yml`;执行 `python3 .code-flow/scripts/cf_spec_context.py refresh --task-dir <需求目录> --root "$PWD" --json`
39
39
  - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage code --json`;required Rule 的 stale/pending/conflict/unverified 任一存在即 FAIL
40
+ - 执行 review 终验:`python3 .code-flow/scripts/cf_task_workflow.py verify-e2e --task-dir <需求目录> --root "$PWD" --json`(聚合需求目录全部 task context 的 `stage: review` verifier + acceptance E2E,按 spec/rule 去重执行一次并写回证据);`decision=pass` 才继续,失败保持阻断
41
+ - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage review --json`;required Rule 存在 unverified review 证据即 FAIL(先完成 review 终验)
40
42
  - 若 `.code-flow/.active-task.json` 仍指向本需求,先执行 `python3 .code-flow/scripts/cf_task_workflow.py finish --root "$PWD" --task-dir "<需求目录>" --task TASK-001 --json`,通过后再 verify-e2e;不得自报 gate_passed 或通过归档绕过 active 状态
41
43
  - Context 与 Evidence 随需求目录一并归档,`_session` 仅是可重建投影,不是事实源
42
44
 
@@ -210,6 +210,23 @@ python3 .code-flow/scripts/cf_task_parallel.py prepare --root "$PWD" \
210
210
  4. 完成时在 worktree 内提交全部改动(含任务文件 Checklist/Evidence/Status 更新),提交信息 `cf-task(<TASK-ID>): <标题>`。
211
211
  5. 返回摘要:TASK-ID、Status、验收命令及结果、提交 SHA、遗留问题。
212
212
 
213
+ 标准 worker prompt 模板(替换占位符后派发):
214
+
215
+ ```
216
+ 你在隔离 worktree 中执行 <TASK-ID>:<worktree 绝对路径>。
217
+ 1) cd 到 worktree;先读 Spec Session(.code-flow/specs/_session/task-<name>.md)、任务文件的当前 TASK 段落与 design 来源章节;
218
+ 2) cf_spec_context.py start → 写验收测试记录 RED → 实现 → GREEN;
219
+ 3) 运行 cf_task_workflow.py finish --root "$PWD" --task <TASK-ID> --json;decision=pass 后【再】提交全部改动(含 finish 回写的 Evidence/状态):git add -A && git commit -m "cf-task(<TASK-ID>): <标题>";
220
+ 4) 返回摘要:TASK-ID、Status、验收命令与结果、commit SHA、遗留问题。
221
+ ```
222
+
223
+ > 提交必须在 finish 之后:finish 通过后会回写 Evidence/状态;collect 默认会自动提交这些回写,但显式提交更清晰。
224
+
225
+ 子 agent 中断(取消/超时)时不要重新 prepare:检查该 worktree 的 `git status`、`git log <base>..HEAD` 与 marker——
226
+ - 无改动且无 marker:直接接管,按单任务模式继续;
227
+ - 有未提交改动或已有提交但未 done:在 worktree 内接管收尾(补实现/测试并 finish),再走 4.3 collect;
228
+ - 已 done 且 marker 已清理:直接进入 4.3。
229
+
213
230
  平台不支持子 agent 时,不建 worktree,直接走 4.6 串行回退。
214
231
 
215
232
  #### 4.3 收集与校验
@@ -219,6 +236,7 @@ python3 .code-flow/scripts/cf_task_parallel.py collect --root "$PWD" --run-id <r
219
236
  ```
220
237
 
221
238
  - 每个任务必须 `ok: true`(改动已提交、Status 为 done/verified、marker 已清理、有提交)。
239
+ - `collect` 在任务 done/verified 且 marker 已清理后,会自动提交 finish 回写的 Evidence/状态(`--no-commit` 关闭并回到严格模式);finish 之外的未提交改动仍应人工确认。
222
240
  - 任一任务失败:停止本批次,不合并;保留 worktree 并列出失败原因。修复后重跑 collect;确认放弃时执行 4.5 cleanup。
223
241
 
224
242
  #### 4.4 回并主分支
@@ -241,6 +259,7 @@ git merge --no-ff -m "merge cf-task <TASK-ID>" <branch>
241
259
  ```
242
260
 
243
261
  - 仍出现冲突(主区期间有新合并):按双方意图解决并重跑双方 functional 验收;通过后提交 merge。
262
+ - 状态文件冲突(`spec-context.yml` / 任务 md 的覆盖状态列 / `.acceptance-manifest.json`)按确定性优先级解决:覆盖状态列取并集(两边各自 verified 的行保留 verified);`spec-context.yml` 取已包含全量规则证据的一侧;manifest 多数情况按行自动合并,冲突时以任务文件为准重建后重跑 functional 场景。解决后必须 `cf_spec_context.py refresh` 收敛 hash,并重跑合并双方的 functional 验收与 `cf_spec_gate --stage code --json`,通过后提交 merge。
244
263
  - 合并后验收失败:记录合并前 HEAD 并 `git reset --hard <合并前HEAD>`(分支与 worktree 原样保留),回到 4.2 让对应子 agent 修复后重新 collect / rebase / 合并。
245
264
 
246
265
  3. 全部任务合并完成后进入 4.5 清理。
@@ -5,7 +5,12 @@ description: Execute deferred E2E scenarios after all functional tests pass
5
5
 
6
6
  ## 使用场景
7
7
 
8
- E2E 测试依赖外部环境(数据库、API、浏览器等),在编码阶段默认跳过(状态 `e2e_deferred`)。当所有子任务的 functional 测试通过后,使用此命令统一执行 E2E 验收。
8
+ 本命令是**需求级终验**入口,在所有子任务的 functional 测试通过后统一执行:
9
+
10
+ 1. **review 层 verifier**:绑定规范中 `stage: review` 的 required verifier(e2e/acceptance/构建等重型验证)。遍历需求目录全部 task context,按 spec/rule 去重执行一次,证据写回全部相关 context;输出 `executed` / `reused` / `failed` 计数。
11
+ 2. **E2E 验收场景**:依赖外部环境(数据库、API、浏览器等)、编码阶段标记 `e2e_deferred` 的场景。
12
+
13
+ 失败不写 verified、不反转已 done 的任务状态;归档前必须 `decision=pass`。未声明 review verifier 且无 E2E 场景时为空操作(`reason=nothing_to_verify`)。
9
14
 
10
15
  ## 调用方式
11
16
 
@@ -36,6 +36,8 @@
36
36
 
37
37
  - 需求目录必须存在 `spec-context.yml`;执行 `python3 .code-flow/scripts/cf_spec_context.py refresh --task-dir <需求目录> --root "$PWD" --json`
38
38
  - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage code --json`;required Rule 的 stale/pending/conflict/unverified 任一存在即 FAIL
39
+ - 执行 review 终验:`python3 .code-flow/scripts/cf_task_workflow.py verify-e2e --task-dir <需求目录> --root "$PWD" --json`(聚合需求目录全部 task context 的 `stage: review` verifier + acceptance E2E,按 spec/rule 去重执行一次并写回证据);`decision=pass` 才继续,失败保持阻断
40
+ - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage review --json`;required Rule 存在 unverified review 证据即 FAIL(先完成 review 终验)
39
41
  - 若 `.code-flow/.active-task.json` 仍指向本需求,先执行 `python3 .code-flow/scripts/cf_task_workflow.py finish --root "$PWD" --task-dir "<需求目录>" --task TASK-001 --json`,通过后再 verify-e2e;不得自报 gate_passed 或通过归档绕过 active 状态
40
42
  - Context 与 Evidence 随需求目录一并归档,`_session` 仅是可重建投影,不是事实源
41
43
 
@@ -209,6 +209,23 @@ python3 .code-flow/scripts/cf_task_parallel.py prepare --root "$PWD" \
209
209
  4. 完成时在 worktree 内提交全部改动(含任务文件 Checklist/Evidence/Status 更新),提交信息 `cf-task(<TASK-ID>): <标题>`。
210
210
  5. 返回摘要:TASK-ID、Status、验收命令及结果、提交 SHA、遗留问题。
211
211
 
212
+ 标准 worker prompt 模板(替换占位符后派发):
213
+
214
+ ```
215
+ 你在隔离 worktree 中执行 <TASK-ID>:<worktree 绝对路径>。
216
+ 1) cd 到 worktree;先读 Spec Session(.code-flow/specs/_session/task-<name>.md)、任务文件的当前 TASK 段落与 design 来源章节;
217
+ 2) cf_spec_context.py start → 写验收测试记录 RED → 实现 → GREEN;
218
+ 3) 运行 cf_task_workflow.py finish --root "$PWD" --task <TASK-ID> --json;decision=pass 后【再】提交全部改动(含 finish 回写的 Evidence/状态):git add -A && git commit -m "cf-task(<TASK-ID>): <标题>";
219
+ 4) 返回摘要:TASK-ID、Status、验收命令与结果、commit SHA、遗留问题。
220
+ ```
221
+
222
+ > 提交必须在 finish 之后:finish 通过后会回写 Evidence/状态;collect 默认会自动提交这些回写,但显式提交更清晰。
223
+
224
+ 子 agent 中断(取消/超时)时不要重新 prepare:检查该 worktree 的 `git status`、`git log <base>..HEAD` 与 marker——
225
+ - 无改动且无 marker:直接接管,按单任务模式继续;
226
+ - 有未提交改动或已有提交但未 done:在 worktree 内接管收尾(补实现/测试并 finish),再走 4.3 collect;
227
+ - 已 done 且 marker 已清理:直接进入 4.3。
228
+
212
229
  平台不支持子 agent 时,不建 worktree,直接走 4.6 串行回退。
213
230
 
214
231
  #### 4.3 收集与校验
@@ -218,6 +235,7 @@ python3 .code-flow/scripts/cf_task_parallel.py collect --root "$PWD" --run-id <r
218
235
  ```
219
236
 
220
237
  - 每个任务必须 `ok: true`(改动已提交、Status 为 done/verified、marker 已清理、有提交)。
238
+ - `collect` 在任务 done/verified 且 marker 已清理后,会自动提交 finish 回写的 Evidence/状态(`--no-commit` 关闭并回到严格模式);finish 之外的未提交改动仍应人工确认。
221
239
  - 任一任务失败:停止本批次,不合并;保留 worktree 并列出失败原因。修复后重跑 collect;确认放弃时执行 4.5 cleanup。
222
240
 
223
241
  #### 4.4 回并主分支
@@ -240,6 +258,7 @@ git merge --no-ff -m "merge cf-task <TASK-ID>" <branch>
240
258
  ```
241
259
 
242
260
  - 仍出现冲突(主区期间有新合并):按双方意图解决并重跑双方 functional 验收;通过后提交 merge。
261
+ - 状态文件冲突(`spec-context.yml` / 任务 md 的覆盖状态列 / `.acceptance-manifest.json`)按确定性优先级解决:覆盖状态列取并集(两边各自 verified 的行保留 verified);`spec-context.yml` 取已包含全量规则证据的一侧;manifest 多数情况按行自动合并,冲突时以任务文件为准重建后重跑 functional 场景。解决后必须 `cf_spec_context.py refresh` 收敛 hash,并重跑合并双方的 functional 验收与 `cf_spec_gate --stage code --json`,通过后提交 merge。
243
262
  - 合并后验收失败:记录合并前 HEAD 并 `git reset --hard <合并前HEAD>`(分支与 worktree 原样保留),回到 4.2 让对应子 agent 修复后重新 collect / rebase / 合并。
244
263
 
245
264
  3. 全部任务合并完成后进入 4.5 清理。
@@ -9,7 +9,12 @@ globs: []
9
9
 
10
10
  ## 使用场景
11
11
 
12
- E2E 测试依赖外部环境(数据库、API、浏览器等),在编码阶段默认跳过(状态 `e2e_deferred`)。当所有子任务的 functional 测试通过后,使用此命令统一执行 E2E 验收。
12
+ 本命令是**需求级终验**入口,在所有子任务的 functional 测试通过后统一执行:
13
+
14
+ 1. **review 层 verifier**:绑定规范中 `stage: review` 的 required verifier(e2e/acceptance/构建等重型验证)。遍历需求目录全部 task context,按 spec/rule 去重执行一次,证据写回全部相关 context;输出 `executed` / `reused` / `failed` 计数。
15
+ 2. **E2E 验收场景**:依赖外部环境(数据库、API、浏览器等)、编码阶段标记 `e2e_deferred` 的场景。
16
+
17
+ 失败不写 verified、不反转已 done 的任务状态;归档前必须 `decision=pass`。未声明 review verifier 且无 E2E 场景时为空操作(`reason=nothing_to_verify`)。
13
18
 
14
19
  ## 调用方式
15
20
 
@@ -41,6 +41,8 @@ description: 归档已完成的 task 文件
41
41
 
42
42
  - 需求目录必须存在 `spec-context.yml`;执行 `python3 .code-flow/scripts/cf_spec_context.py refresh --task-dir <需求目录> --root "$PWD" --json`
43
43
  - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage code --json`;required Rule 的 stale/pending/conflict/unverified 任一存在即 FAIL
44
+ - 执行 review 终验:`python3 .code-flow/scripts/cf_task_workflow.py verify-e2e --task-dir <需求目录> --root "$PWD" --json`(聚合需求目录全部 task context 的 `stage: review` verifier + acceptance E2E,按 spec/rule 去重执行一次并写回证据);`decision=pass` 才继续,失败保持阻断
45
+ - 执行 `python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage review --json`;required Rule 存在 unverified review 证据即 FAIL(先完成 review 终验)
44
46
  - 若 `.code-flow/.active-task.json` 仍指向本需求,先执行 `python3 .code-flow/scripts/cf_task_workflow.py finish --root "$PWD" --task-dir "<需求目录>" --task TASK-001 --json`,通过后再 verify-e2e;不得自报 gate_passed 或通过归档绕过 active 状态
45
47
  - Context 与 Evidence 随需求目录一并归档,`_session` 仅是可重建投影,不是事实源
46
48
 
@@ -214,6 +214,23 @@ python3 .code-flow/scripts/cf_task_parallel.py prepare --root "$PWD" \
214
214
  4. 完成时在 worktree 内提交全部改动(含任务文件 Checklist/Evidence/Status 更新),提交信息 `cf-task(<TASK-ID>): <标题>`。
215
215
  5. 返回摘要:TASK-ID、Status、验收命令及结果、提交 SHA、遗留问题。
216
216
 
217
+ 标准 worker prompt 模板(替换占位符后派发):
218
+
219
+ ```
220
+ 你在隔离 worktree 中执行 <TASK-ID>:<worktree 绝对路径>。
221
+ 1) cd 到 worktree;先读 Spec Session(.code-flow/specs/_session/task-<name>.md)、任务文件的当前 TASK 段落与 design 来源章节;
222
+ 2) cf_spec_context.py start → 写验收测试记录 RED → 实现 → GREEN;
223
+ 3) 运行 cf_task_workflow.py finish --root "$PWD" --task <TASK-ID> --json;decision=pass 后【再】提交全部改动(含 finish 回写的 Evidence/状态):git add -A && git commit -m "cf-task(<TASK-ID>): <标题>";
224
+ 4) 返回摘要:TASK-ID、Status、验收命令与结果、commit SHA、遗留问题。
225
+ ```
226
+
227
+ > 提交必须在 finish 之后:finish 通过后会回写 Evidence/状态;collect 默认会自动提交这些回写,但显式提交更清晰。
228
+
229
+ 子 agent 中断(取消/超时)时不要重新 prepare:检查该 worktree 的 `git status`、`git log <base>..HEAD` 与 marker——
230
+ - 无改动且无 marker:直接接管,按单任务模式继续;
231
+ - 有未提交改动或已有提交但未 done:在 worktree 内接管收尾(补实现/测试并 finish),再走 4.3 collect;
232
+ - 已 done 且 marker 已清理:直接进入 4.3。
233
+
217
234
  平台不支持子 agent 时,不建 worktree,直接走 4.6 串行回退。
218
235
 
219
236
  #### 4.3 收集与校验
@@ -223,6 +240,7 @@ python3 .code-flow/scripts/cf_task_parallel.py collect --root "$PWD" --run-id <r
223
240
  ```
224
241
 
225
242
  - 每个任务必须 `ok: true`(改动已提交、Status 为 done/verified、marker 已清理、有提交)。
243
+ - `collect` 在任务 done/verified 且 marker 已清理后,会自动提交 finish 回写的 Evidence/状态(`--no-commit` 关闭并回到严格模式);finish 之外的未提交改动仍应人工确认。
226
244
  - 任一任务失败:停止本批次,不合并;保留 worktree 并列出失败原因。修复后重跑 collect;确认放弃时执行 4.5 cleanup。
227
245
 
228
246
  #### 4.4 回并主分支
@@ -245,6 +263,7 @@ git merge --no-ff -m "merge cf-task <TASK-ID>" <branch>
245
263
  ```
246
264
 
247
265
  - 仍出现冲突(主区期间有新合并):按双方意图解决并重跑双方 functional 验收;通过后提交 merge。
266
+ - 状态文件冲突(`spec-context.yml` / 任务 md 的覆盖状态列 / `.acceptance-manifest.json`)按确定性优先级解决:覆盖状态列取并集(两边各自 verified 的行保留 verified);`spec-context.yml` 取已包含全量规则证据的一侧;manifest 多数情况按行自动合并,冲突时以任务文件为准重建后重跑 functional 场景。解决后必须 `cf_spec_context.py refresh` 收敛 hash,并重跑合并双方的 functional 验收与 `cf_spec_gate --stage code --json`,通过后提交 merge。
248
267
  - 合并后验收失败:记录合并前 HEAD 并 `git reset --hard <合并前HEAD>`(分支与 worktree 原样保留),回到 4.2 让对应子 agent 修复后重新 collect / rebase / 合并。
249
268
 
250
269
  3. 全部任务合并完成后进入 4.5 清理。
@@ -9,7 +9,12 @@ globs: []
9
9
 
10
10
  ## 使用场景
11
11
 
12
- E2E 测试依赖外部环境(数据库、API、浏览器等),在编码阶段默认跳过(状态 `e2e_deferred`)。当所有子任务的 functional 测试通过后,使用此命令统一执行 E2E 验收。
12
+ 本命令是**需求级终验**入口,在所有子任务的 functional 测试通过后统一执行:
13
+
14
+ 1. **review 层 verifier**:绑定规范中 `stage: review` 的 required verifier(e2e/acceptance/构建等重型验证)。遍历需求目录全部 task context,按 spec/rule 去重执行一次,证据写回全部相关 context;输出 `executed` / `reused` / `failed` 计数。
15
+ 2. **E2E 验收场景**:依赖外部环境(数据库、API、浏览器等)、编码阶段标记 `e2e_deferred` 的场景。
16
+
17
+ 失败不写 verified、不反转已 done 的任务状态;归档前必须 `decision=pass`。未声明 review verifier 且无 E2E 场景时为空操作(`reason=nothing_to_verify`)。
13
18
 
14
19
  ## 调用方式
15
20
 
@@ -101,6 +101,7 @@ class RuleBinding:
101
101
  enforcement: str
102
102
  verifier_ref: str
103
103
  stage_status: Mapping[str, RuleStageStatus]
104
+ verifier_stage: str = "code"
104
105
 
105
106
 
106
107
  @dataclass(frozen=True)
@@ -806,6 +807,7 @@ def _rule_from_data(value: object, path: str) -> RuleBinding:
806
807
  _string(data.get("enforcement"), "rules[].enforcement", path),
807
808
  _string(data.get("verifier_ref"), "rules[].verifier_ref", path),
808
809
  parsed,
810
+ _string(data.get("verifier_stage", "code"), "rules[].verifier_stage", path),
809
811
  )
810
812
 
811
813
 
@@ -816,6 +818,7 @@ def _rule_data(rule: RuleBinding) -> dict[str, object]:
816
818
  "text_sha256": rule.text_sha256,
817
819
  "enforcement": rule.enforcement,
818
820
  "verifier_ref": rule.verifier_ref,
821
+ "verifier_stage": rule.verifier_stage,
819
822
  "stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
820
823
  }
821
824
 
@@ -990,7 +993,12 @@ def _binding_from_input(selection: BindingInput) -> SpecBinding:
990
993
  }
991
994
  verifier = verifier_by_rule.get(rule.ref)
992
995
  verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
993
- rules.append(RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses))
996
+ rules.append(
997
+ RuleBinding(
998
+ rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
999
+ verifier.stage if verifier is not None else "code",
1000
+ )
1001
+ )
994
1002
  return SpecBinding(
995
1003
  candidate.spec_id,
996
1004
  candidate.path,
@@ -1003,12 +1011,26 @@ def _binding_from_input(selection: BindingInput) -> SpecBinding:
1003
1011
  )
1004
1012
 
1005
1013
 
1014
+ def _with_declared_stages(rule: RuleBinding, stages: Sequence[str]) -> RuleBinding:
1015
+ """兼容补齐:绑定旧版本时声明的 stages 缺状态,按声明补 pending。"""
1016
+ missing = [stage for stage in stages if stage not in rule.stage_status]
1017
+ if not missing:
1018
+ return rule
1019
+ statuses = dict(rule.stage_status)
1020
+ for stage in missing:
1021
+ statuses[stage] = RuleStageStatus("pending", (), None, ())
1022
+ return replace(rule, stage_status=statuses)
1023
+
1024
+
1006
1025
  def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
1007
1026
  enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
1008
1027
  statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
1009
- has_verifier = any(item.rule == rule.ref for item in metadata.verifiers)
1010
- verifier_ref = f"{spec_id}#{rule.ref}" if has_verifier else "advisory:none"
1011
- return RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses)
1028
+ verifier = next((item for item in metadata.verifiers if item.rule == rule.ref), None)
1029
+ verifier_ref = f"{spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
1030
+ return RuleBinding(
1031
+ rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
1032
+ verifier.stage if verifier is not None else "code",
1033
+ )
1012
1034
 
1013
1035
 
1014
1036
  def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
@@ -1156,14 +1178,15 @@ def _refresh_existing_rule(
1156
1178
  binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
1157
1179
  ) -> tuple[RuleBinding, Optional[DriftChange]]:
1158
1180
  enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
1159
- has_verifier = any(item.rule == current.ref for item in metadata.verifiers)
1160
- verifier_ref = f"{binding.spec_id}#{current.ref}" if has_verifier else "advisory:none"
1181
+ verifier = next((item for item in metadata.verifiers if item.rule == current.ref), None)
1182
+ verifier_ref = f"{binding.spec_id}#{current.ref}" if verifier is not None else "advisory:none"
1161
1183
  updated = replace(
1162
1184
  existing,
1163
1185
  summary=current.text,
1164
1186
  text_sha256=current.text_sha256,
1165
1187
  enforcement=enforcement,
1166
1188
  verifier_ref=verifier_ref,
1189
+ verifier_stage=verifier.stage if verifier is not None else "code",
1167
1190
  )
1168
1191
  if existing.text_sha256 == current.text_sha256:
1169
1192
  return updated, None
@@ -1200,6 +1223,7 @@ def _refresh_binding(
1200
1223
  rule, change = _refresh_existing_rule(binding, prior, current, metadata)
1201
1224
  if change is not None:
1202
1225
  changes.append(change)
1226
+ rule = _with_declared_stages(rule, metadata.stages)
1203
1227
  rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
1204
1228
  rules.append(rule)
1205
1229
  changes.extend(artifact_changes)
@@ -1571,11 +1595,13 @@ def _status_command(args: argparse.Namespace) -> dict[str, object]:
1571
1595
  rules = []
1572
1596
  for rule in binding.rules:
1573
1597
  status = rule.stage_status.get("code")
1598
+ stages = {stage: item.status for stage, item in sorted(rule.stage_status.items())}
1574
1599
  rules.append(
1575
1600
  {
1576
1601
  "ref": f"{binding.spec_id}#{rule.ref}",
1577
1602
  "enforcement": rule.enforcement,
1578
1603
  "status": status.status if status else "missing",
1604
+ "stages": stages,
1579
1605
  "verifier": rule.verifier_ref,
1580
1606
  }
1581
1607
  )
@@ -1609,7 +1635,8 @@ def _status_text(data: dict[str, object]) -> str:
1609
1635
  for binding in data["bindings"]:
1610
1636
  lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
1611
1637
  for rule in binding["rules"]:
1612
- lines.append(f" {rule['status']:<12} {rule['ref']}({rule['verifier']})")
1638
+ stage_text = " ".join(f"{stage}={value}" for stage, value in rule.get("stages", {}).items())
1639
+ lines.append(f" {stage_text:<24} {rule['ref']}({rule['verifier']})")
1613
1640
  return "\n".join(lines)
1614
1641
 
1615
1642
 
@@ -116,6 +116,8 @@ def _binding_issues(
116
116
  status = rule.stage_status.get(stage)
117
117
  if status is None:
118
118
  continue
119
+ if stage in ("code", "review") and getattr(rule, "verifier_stage", "code") != stage:
120
+ continue
119
121
  if rule.enforcement == "advisory" and status.status == "pending":
120
122
  continue
121
123
  code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256, allow_cheap_skips)
@@ -15,6 +15,7 @@ from typing import Mapping, Optional
15
15
  ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
16
16
  ALLOWED_ENFORCEMENT = frozenset(("required", "advisory"))
17
17
  ALLOWED_VERIFIERS = frozenset(("document", "regex", "ast", "command", "test", "manual"))
18
+ ALLOWED_VERIFIER_STAGES = frozenset(("code", "review"))
18
19
  _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
19
20
  _RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
20
21
  _EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
@@ -52,6 +53,8 @@ class SpecVerifier:
52
53
  rule: str
53
54
  type: str
54
55
  config: Mapping[str, object]
56
+ stage: str = "code"
57
+ files: tuple[str, ...] = ()
55
58
 
56
59
 
57
60
  @dataclass(frozen=True)
@@ -209,7 +212,26 @@ def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[M
209
212
  return tuple(value)
210
213
 
211
214
 
212
- def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[SpecVerifier, ...]:
215
+ def _parse_verifier_files(value: object, path: str, raw: str, index: int) -> tuple[str, ...]:
216
+ line = _verifier_field_line(raw, index, "files")
217
+ if not isinstance(value, list) or not value:
218
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, "必须是非空 glob 字符串数组")
219
+ files: list[str] = []
220
+ for item in value:
221
+ if not isinstance(item, str) or not item.strip():
222
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, "glob 必须是非空字符串")
223
+ text = item.strip().replace("\\", "/")
224
+ if text.startswith("/") or re.match(r"^[A-Za-z]:/", text):
225
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止绝对路径: {item!r}")
226
+ if ".." in text.split("/"):
227
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止 .. 跳出仓库: {item!r}")
228
+ files.append(text)
229
+ return tuple(files)
230
+
231
+
232
+ def _parse_verifiers(
233
+ meta: Mapping[str, object], path: str, raw: str, stages: tuple[str, ...]
234
+ ) -> tuple[SpecVerifier, ...]:
213
235
  value = meta.get("verifiers", [])
214
236
  if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
215
237
  raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), "必须是 mapping 列表")
@@ -219,6 +241,8 @@ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[S
219
241
  rule = item.get("rule")
220
242
  kind = item.get("type")
221
243
  config = item.get("config")
244
+ explicit_stage = "stage" in item
245
+ stage = item.get("stage", "code")
222
246
  if not isinstance(rule, str) or not _RULE_ID_RE.fullmatch(rule):
223
247
  line = _verifier_field_line(raw, index, "rule")
224
248
  raise SpecMetadataError(path, f"verifiers[{index}].rule", line, "必须是稳定 RULE ID")
@@ -228,10 +252,17 @@ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[S
228
252
  if not isinstance(config, dict):
229
253
  line = _verifier_field_line(raw, index, "config")
230
254
  raise SpecMetadataError(path, f"verifiers[{index}].config", line, "必须是 mapping")
255
+ if explicit_stage and (not isinstance(stage, str) or stage not in ALLOWED_VERIFIER_STAGES):
256
+ line = _verifier_field_line(raw, index, "stage")
257
+ raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"必须是 {sorted(ALLOWED_VERIFIER_STAGES)} 之一")
258
+ if explicit_stage and stage not in stages:
259
+ line = _verifier_field_line(raw, index, "stage")
260
+ raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"stage {stage!r} 未在 stages 声明: {', '.join(stages)}")
261
+ files = _parse_verifier_files(item["files"], path, raw, index) if "files" in item else ()
231
262
  if rule in seen:
232
263
  raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), f"重复 verifier: {rule}")
233
264
  seen.add(rule)
234
- result.append(SpecVerifier(rule=rule, type=kind, config=config))
265
+ result.append(SpecVerifier(rule=rule, type=kind, config=config, stage=stage, files=files))
235
266
  return tuple(result)
236
267
 
237
268
 
@@ -324,7 +355,7 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
324
355
  if owner is not None and (not isinstance(owner, str) or not owner.strip()):
325
356
  raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
326
357
  checks = _validate_checks(meta, path, raw_frontmatter)
327
- verifiers = _parse_verifiers(meta, path, raw_frontmatter)
358
+ verifiers = _parse_verifiers(meta, path, raw_frontmatter, header.stages)
328
359
  body_line_offset = raw_frontmatter.count("\n") + 3
329
360
  rules = _parse_rules(body, path, body_line_offset)
330
361
  _validate_links(rules, verifiers, path, raw_frontmatter)
@@ -4,7 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import ast
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, replace
8
8
  from datetime import datetime, timezone
9
9
  import fnmatch
10
10
  import hashlib
@@ -59,19 +59,94 @@ def _cache_file(root: str) -> Path:
59
59
  return Path(root) / ".code-flow" / ".verifier-cache.json"
60
60
 
61
61
 
62
- def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
62
+ def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]], stage: str = "code") -> str:
63
63
  payload = {
64
64
  "spec": metadata.hashes.file_sha256,
65
65
  "rule": rule.text_sha256,
66
66
  "verifier": {"type": verifier.type, "config": verifier.config},
67
67
  "files": scope.files,
68
68
  "diff": scope.diff_sha256,
69
+ "stage": stage,
69
70
  "confirmation": confirmation or {},
70
71
  "artifact_content": _artifact_fingerprint(verifier, scope),
71
72
  }
72
73
  return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
73
74
 
74
75
 
76
+ def _normalize_scope_path(value: str) -> str:
77
+ return value.replace("\\", "/").strip()
78
+
79
+
80
+ def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
81
+ for pattern in patterns:
82
+ if fnmatch.fnmatchcase(path, pattern):
83
+ return True
84
+ bare = pattern.rstrip("/")
85
+ if not any(char in pattern for char in "*?[") and path.startswith(bare + "/"):
86
+ return True
87
+ return False
88
+
89
+
90
+ def _git_tracked_files(root: str) -> tuple[str, ...]:
91
+ try:
92
+ outcome = subprocess.run(
93
+ ("git", "ls-files"), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
94
+ )
95
+ except OSError:
96
+ return ()
97
+ if outcome.returncode != 0:
98
+ return ()
99
+ return tuple(_normalize_scope_path(line) for line in outcome.stdout.splitlines() if line.strip())
100
+
101
+
102
+ def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
103
+ """作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
104
+
105
+ 决议仓库级集合(而非仅当前任务 diff)才能让未触及作用域的其他任务命中缓存。
106
+ """
107
+ if not verifier.files:
108
+ return ()
109
+ candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
110
+ candidates.update(_git_tracked_files(scope.root))
111
+ return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
112
+
113
+
114
+ def _scope_fingerprint(root: str, files: Sequence[str]) -> str:
115
+ digest = hashlib.sha256()
116
+ for relative in files:
117
+ path = Path(root) / relative
118
+ digest.update(relative.encode("utf-8"))
119
+ try:
120
+ digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
121
+ except OSError:
122
+ digest.update(b"<unreadable>")
123
+ return digest.hexdigest()
124
+
125
+
126
+ def _scoped_cache_key(
127
+ metadata: SpecMetadata,
128
+ rule: SpecRule,
129
+ verifier: SpecVerifier,
130
+ scope: VerificationScope,
131
+ confirmation: Optional[Mapping[str, object]],
132
+ stage: str,
133
+ ) -> Optional[str]:
134
+ """command/test 仅在声明 files 作用域时缓存;未声明保持全量执行。"""
135
+ if verifier.type not in ("command", "test") or not verifier.files:
136
+ return None
137
+ files = scoped_files(verifier, scope)
138
+ payload = {
139
+ "spec": metadata.hashes.file_sha256,
140
+ "rule": rule.text_sha256,
141
+ "verifier": {"type": verifier.type, "config": verifier.config, "files": list(verifier.files)},
142
+ "stage": stage,
143
+ "scope_files": files,
144
+ "scope_sha256": _scope_fingerprint(scope.root, files),
145
+ "confirmation": confirmation or {},
146
+ }
147
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
148
+
149
+
75
150
  def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
76
151
  if verifier.type != "document":
77
152
  return None
@@ -143,13 +218,32 @@ def _cached_or_run(
143
218
  verifier: SpecVerifier,
144
219
  scope: VerificationScope,
145
220
  confirmation: Optional[Mapping[str, object]],
221
+ stage: str = "code",
146
222
  ) -> VerificationEvidence:
147
223
  cacheable = verifier.type in _PURE_VERIFIER_TYPES
148
- key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
224
+ scoped_key: Optional[str] = None
225
+ if cacheable:
226
+ key = _cache_key(metadata, rule, verifier, scope, confirmation, stage)
227
+ else:
228
+ scoped_key = _scoped_cache_key(metadata, rule, verifier, scope, confirmation, stage)
229
+ if scoped_key is None:
230
+ key = ""
231
+ else:
232
+ cacheable, key = True, scoped_key
149
233
  if cacheable:
150
234
  cached = _cached_evidence(key)
151
235
  if cached is not None:
152
- return cached
236
+ if scoped_key is None:
237
+ return cached
238
+ details = dict(cached.details or {})
239
+ details["cache_reused"] = True
240
+ details["scope_files"] = list(scoped_files(verifier, scope))
241
+ return replace(
242
+ cached,
243
+ executed_at=datetime.now(timezone.utc).isoformat(),
244
+ diff_sha256=scope.diff_sha256,
245
+ details=details,
246
+ )
153
247
  current = _evidence(metadata, rule, verifier, scope, confirmation)
154
248
  if cacheable and current.status == "verified":
155
249
  _save_result_cache(key, current)
@@ -400,6 +494,7 @@ def _run_all_verifiers(
400
494
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
401
495
  skip_command: bool = False,
402
496
  timeout_budget: Optional[float] = None,
497
+ stage: str = "code",
403
498
  ) -> VerificationResult:
404
499
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
405
500
  confirmation_by_rule = confirmations or {}
@@ -410,12 +505,14 @@ def _run_all_verifiers(
410
505
  for rule in metadata.rules:
411
506
  if rule.enforcement != "required":
412
507
  continue
508
+ verifier = verifier_by_rule.get(rule.ref)
509
+ if verifier is not None and verifier.stage != stage:
510
+ continue
413
511
  if timeout_budget is not None:
414
512
  remaining = timeout_budget - (time.monotonic() - started)
415
513
  if remaining <= 0:
416
514
  evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
417
515
  continue
418
- verifier = verifier_by_rule.get(rule.ref)
419
516
  if verifier is None:
420
517
  evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
421
518
  continue
@@ -427,11 +524,11 @@ def _run_all_verifiers(
427
524
  parallel.append((rule, verifier, confirmation))
428
525
  continue
429
526
  cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
430
- evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
527
+ evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation, stage) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
431
528
  if parallel:
432
529
  with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
433
530
  futures = {
434
- rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
531
+ rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
435
532
  for rule, verifier, confirmation in parallel
436
533
  }
437
534
  evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
@@ -442,9 +539,10 @@ def _run_all_verifiers(
442
539
 
443
540
  def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
444
541
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
445
- skip_command: bool = False, timeout_budget: Optional[float] = None) -> VerificationResult:
542
+ skip_command: bool = False, timeout_budget: Optional[float] = None,
543
+ stage: str = "code") -> VerificationResult:
446
544
  with execution_session():
447
- return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
545
+ return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage)
448
546
 
449
547
 
450
548
  def evidence_is_fresh(
@@ -266,20 +266,12 @@ def prepare(root: Path, task_file: str, tasks: Sequence[str], run_id: Optional[s
266
266
  "base_head": outcome["base_head"], "worktrees": worktrees}
267
267
 
268
268
 
269
- def _collect_one(root: Path, entry: dict[str, object], base_head: str, task_file: str) -> dict[str, object]:
269
+ def _collect_one(root: Path, entry: dict[str, object], base_head: str, task_file: str, commit: bool = True) -> dict[str, object]:
270
270
  task = str(entry["task"])
271
271
  path = Path(str(entry["path"]))
272
272
  branch = str(entry["branch"])
273
273
  if not path.is_dir():
274
274
  raise ParallelError("worktree_missing", f"{task}: worktree 不存在: {path}")
275
- dirty = _status_paths(path)
276
- if dirty:
277
- raise ParallelError("worktree_dirty", f"{task}: 尚有未提交改动: {', '.join(dirty[:8])}")
278
- if (path / MARKER).exists():
279
- raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
280
- head = _run_git(path, ("rev-parse", "HEAD")).strip()
281
- if head == base_head:
282
- raise ParallelError("no_commits", f"{task}: worktree 无新提交")
283
275
  try:
284
276
  nodes = parse_task_file(str(path / task_file))
285
277
  except ValueError as exc:
@@ -287,12 +279,28 @@ def _collect_one(root: Path, entry: dict[str, object], base_head: str, task_file
287
279
  status = next((node.status for node in nodes if node.task_id == task), None)
288
280
  if status not in FINISHED_STATUSES:
289
281
  raise ParallelError("task_not_finished", f"{task}: Status 为 {status},未通过 Done Gate")
282
+ if (path / MARKER).exists():
283
+ raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
284
+ dirty = _status_paths(path)
285
+ if dirty:
286
+ # finish 会在通过后回写 Evidence/状态;任务已 done 且 marker 已清理时,
287
+ # 这些回写由 collect 自动提交(--no-commit 可关闭,保持严格模式)。
288
+ if not commit:
289
+ raise ParallelError(
290
+ "worktree_dirty",
291
+ f"{task}: 尚有未提交改动: {', '.join(dirty[:8])};可在 worktree 内提交后重跑 collect,或去掉 --no-commit",
292
+ )
293
+ _run_git(path, ("add", "-A"))
294
+ _run_git(path, ("commit", "-q", "-m", f"cf-task({task}): finish 回写验收证据与任务状态"))
295
+ head = _run_git(path, ("rev-parse", "HEAD")).strip()
296
+ if head == base_head:
297
+ raise ParallelError("no_commits", f"{task}: worktree 无新提交")
290
298
  files = _run_git(root, ("diff", "--name-only", f"{base_head}..{branch}")).splitlines()
291
299
  return {"task": task, "branch": branch, "path": str(path), "head": head,
292
300
  "status": status, "files": sorted(item for item in files if item)}
293
301
 
294
302
 
295
- def collect(root: Path, run_id: str, tasks: Optional[Sequence[str]]) -> dict[str, object]:
303
+ def collect(root: Path, run_id: str, tasks: Optional[Sequence[str]], commit: bool = True) -> dict[str, object]:
296
304
  root = root.resolve()
297
305
  meta = _read_run_meta(root, run_id)
298
306
  entries = [item for item in meta.get("worktrees", []) if isinstance(item, dict)]
@@ -302,7 +310,7 @@ def collect(root: Path, run_id: str, tasks: Optional[Sequence[str]]) -> dict[str
302
310
  results: list[dict[str, object]] = []
303
311
  for entry in entries:
304
312
  try:
305
- results.append(_collect_one(root, entry, str(meta["base_head"]), str(meta["task_file"])))
313
+ results.append(_collect_one(root, entry, str(meta["base_head"]), str(meta["task_file"]), commit))
306
314
  except ParallelError as exc:
307
315
  results.append({"task": str(entry.get("task", "")), "ok": False, "code": exc.code, "message": exc.message})
308
316
  ok = bool(results) and all(item.get("code") is None for item in results)
@@ -363,6 +371,7 @@ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) ->
363
371
  for name in ("collect", "cleanup"):
364
372
  sub.choices[name].add_argument("--run-id", required=True)
365
373
  sub.choices["collect"].add_argument("--tasks", default="")
374
+ sub.choices["collect"].add_argument("--no-commit", action="store_true")
366
375
  sub.choices["cleanup"].add_argument("--force", action="store_true")
367
376
  args = parser.parse_args(argv)
368
377
  root = Path(args.root).resolve()
@@ -372,7 +381,7 @@ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) ->
372
381
  payload = prepare(root, args.task_file, tasks, args.run_id or None)
373
382
  elif args.action == "collect":
374
383
  tasks = [item.strip() for item in args.tasks.split(",") if item.strip()] or None
375
- payload = collect(root, args.run_id, tasks)
384
+ payload = collect(root, args.run_id, tasks, commit=not args.no_commit)
376
385
  else:
377
386
  payload = cleanup(root, args.run_id, args.force)
378
387
  except ParallelError as exc:
@@ -48,6 +48,7 @@ class DoneResult:
48
48
  files: tuple[str, ...]
49
49
  evidence: tuple[Mapping[str, object], ...]
50
50
  message: str = ""
51
+ deferred_review: int = 0
51
52
 
52
53
 
53
54
  def _context_path(task_dir: str) -> str:
@@ -153,10 +154,11 @@ def _evidence_data(evidence: VerificationEvidence) -> Mapping[str, object]:
153
154
 
154
155
 
155
156
  def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBinding:
156
- if "code" not in rule.stage_status:
157
+ stage = rule.verifier_stage or "code"
158
+ if stage not in rule.stage_status:
157
159
  return rule
158
160
  statuses = dict(rule.stage_status)
159
- current = statuses["code"]
161
+ current = statuses[stage]
160
162
  if current.status in ("not_applicable", "waived"):
161
163
  return rule
162
164
  status = "verified" if evidence.get("status") == "verified" else "unverified"
@@ -168,9 +170,9 @@ def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBindi
168
170
  (item.get("verifier_ref"), item.get("result_sha256"), item.get("diff_sha256")) == signature
169
171
  for item in current.evidence
170
172
  ):
171
- statuses["code"] = replace(current, status=status)
173
+ statuses[stage] = replace(current, status=status)
172
174
  else:
173
- statuses["code"] = replace(current, status=status, evidence=(*current.evidence, evidence))
175
+ statuses[stage] = replace(current, status=status, evidence=(*current.evidence, evidence))
174
176
  return replace(rule, stage_status=statuses)
175
177
 
176
178
 
@@ -267,9 +269,11 @@ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Option
267
269
  diff_hash = _diff_hash(root, scope_result.files)
268
270
  phase_timing("done.load_context_and_diff", phase_started)
269
271
  all_evidence: list[Mapping[str, object]] = []
272
+ deferred_review = 0
270
273
  for binding in context.bindings:
271
274
  phase_started = time.monotonic()
272
275
  metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
276
+ deferred_review += sum(1 for rule in binding.rules if rule.verifier_stage == "review")
273
277
  confirmations: dict[str, Mapping[str, object]] = {}
274
278
  for rule in binding.rules:
275
279
  confirmation = _rule_manual_confirmation(rule)
@@ -277,7 +281,8 @@ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Option
277
281
  confirmations[rule.ref] = confirmation
278
282
  remaining = None if budget is None else budget - (time.monotonic() - started)
279
283
  result = run_all_verifiers(
280
- metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining
284
+ metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining,
285
+ stage="code",
281
286
  )
282
287
  phase_timing(f"done.verify.{binding.spec_id}", phase_started)
283
288
  all_evidence.extend(_evidence_data(item) for item in result.evidence)
@@ -288,10 +293,10 @@ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Option
288
293
  if gate.decision == "pass" and not cheap:
289
294
  validation_issue = _run_finish_validation(root, scope_result.files)
290
295
  if validation_issue:
291
- return DoneResult("block", scope_result.files, tuple(all_evidence), validation_issue)
296
+ return DoneResult("block", scope_result.files, tuple(all_evidence), validation_issue, deferred_review)
292
297
  phase_timing("done.total", started)
293
298
  return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
294
- "; ".join(issue.message for issue in gate.errors))
299
+ "; ".join(issue.message for issue in gate.errors), deferred_review)
295
300
 
296
301
 
297
302
  def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
@@ -11,8 +11,12 @@ import sys
11
11
 
12
12
  from cf_acceptance_runner import run_manifest
13
13
  from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
14
+ from cf_spec_context import ContextError, _git_changes, _run_git, load_context, save_context
15
+ from cf_spec_gate import validate_stage
16
+ from cf_spec_metadata import load_spec_metadata
17
+ from cf_spec_verify import VerificationScope, _git_tracked_files, run_all_verifiers
14
18
  from cf_task_index import parse_task_file
15
- from cf_task_runtime import run_done_gate
19
+ from cf_task_runtime import _apply_evidence, _evidence_data, run_done_gate
16
20
  from cf_workflow_service import (FINISHED_STATUSES, _require_marker_task,
17
21
  _sync_markdown, block_task, complete_task, locate_task_file, resume_task)
18
22
  from cf_workflow_transaction import recover_transition
@@ -23,7 +27,10 @@ def finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict
23
27
  gate = run_done_gate(root, directory, task_id=task_id)
24
28
  if gate.decision != "pass":
25
29
  return {"decision": "block", "reason": gate.message, "evidence": gate.evidence}
26
- return {"decision": "pass", **complete_task(root, directory, task_file, task_id, True)}
30
+ result = {"decision": "pass", "deferred_review": gate.deferred_review, **complete_task(root, directory, task_file, task_id, True)}
31
+ if gate.deferred_review:
32
+ result["deferred_hint"] = f"{gate.deferred_review} review-layer verifier(s) deferred; run cf-task:verify-e2e for the requirement directory"
33
+ return result
27
34
 
28
35
 
29
36
  def _demand_tasks(directory: Path) -> list[tuple[Path, str, str]]:
@@ -44,6 +51,79 @@ def _demand_tasks(directory: Path) -> list[tuple[Path, str, str]]:
44
51
  return tasks
45
52
 
46
53
 
54
+ def _head_token(root: str) -> str:
55
+ try:
56
+ return "head:" + _run_git(root, ("rev-parse", "HEAD")).strip()
57
+ except ContextError:
58
+ return "head:none"
59
+
60
+
61
+ def _review_scope(root: str) -> VerificationScope:
62
+ files = set(_git_tracked_files(root))
63
+ try:
64
+ files.update(_git_changes(root))
65
+ except ContextError:
66
+ pass
67
+ return VerificationScope(root, tuple(sorted(files)), _head_token(root))
68
+
69
+
70
+ def collect_review_bindings(directory: Path) -> dict[str, dict[str, object]]:
71
+ """收集需求目录下全部 task context 的 review 层绑定。
72
+
73
+ 返回 spec_id -> {"path": spec 相对路径, "rules": {rule_ref: [(context_path, context), ...]}}。
74
+ 读取失败 fail-closed(报告文件与原因),不允许静默跳过。
75
+ """
76
+ targets: dict[str, dict[str, object]] = {}
77
+ for ctx_path in sorted(directory.rglob("spec-context.yml")):
78
+ try:
79
+ context = load_context(str(ctx_path))
80
+ except (ContextError, OSError, ValueError) as exc:
81
+ raise ValueError(f"review_context_unreadable: {ctx_path}: {exc}") from exc
82
+ for binding in context.bindings:
83
+ for rule in binding.rules:
84
+ if rule.verifier_stage != "review":
85
+ continue
86
+ entry = targets.setdefault(binding.spec_id, {"path": binding.path, "rules": {}})
87
+ entry["rules"].setdefault(rule.ref, []).append((ctx_path, context))
88
+ return targets
89
+
90
+
91
+ def _run_review_targets(root: str, targets: dict[str, dict[str, object]]) -> dict[str, object]:
92
+ """执行 review 层 verifier(按 spec/rule 去重),证据写回全部相关 context。"""
93
+ executed = reused = 0
94
+ failures: list[str] = []
95
+ context_paths: set[Path] = set()
96
+ scope = _review_scope(root)
97
+ for spec_id in sorted(targets):
98
+ info = targets[spec_id]
99
+ metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / str(info["path"])))
100
+ result = run_all_verifiers(metadata, scope, stage="review")
101
+ by_rule: dict[str, object] = {}
102
+ for evidence in result.evidence:
103
+ rule_ref = evidence.verifier_ref.split("#", 1)[-1]
104
+ by_rule[rule_ref] = evidence
105
+ if evidence.status != "verified":
106
+ failures.append(evidence.verifier_ref)
107
+ elif (evidence.details or {}).get("cache_reused"):
108
+ reused += 1
109
+ else:
110
+ executed += 1
111
+ for rule_ref, entries in info["rules"].items():
112
+ evidence = by_rule.get(rule_ref)
113
+ if evidence is None:
114
+ continue
115
+ payload = (_evidence_data(evidence),)
116
+ for ctx_path, context in entries:
117
+ context_paths.add(ctx_path)
118
+ updated = _apply_evidence(context, payload)
119
+ if updated != context:
120
+ save_context(str(ctx_path), updated)
121
+ for ctx_path in sorted(context_paths):
122
+ gate = validate_stage(load_context(str(ctx_path)), "review", diff_sha256=scope.diff_sha256)
123
+ failures.extend(f"{issue.spec_id}#{issue.rule_ref}" for issue in gate.errors)
124
+ return {"executed": executed, "reused": reused, "failed": sorted(set(failures))}
125
+
126
+
47
127
  def verify_e2e(root: str, directory: str) -> dict[str, object]:
48
128
  recover_transition(root)
49
129
  tasks = _demand_tasks(Path(directory))
@@ -52,18 +132,36 @@ def verify_e2e(root: str, directory: str) -> dict[str, object]:
52
132
  return {"decision": "block", "reason": "unfinished_tasks", "tasks": unfinished}
53
133
  if (Path(root) / ".code-flow/.active-task.json").exists():
54
134
  return {"decision": "block", "reason": "finish_active_task_first"}
135
+ try:
136
+ targets = collect_review_bindings(Path(directory))
137
+ except ValueError as exc:
138
+ return {"decision": "block", "reason": "review_context_unreadable", "detail": str(exc)}
55
139
  manifest = Path(directory) / ".acceptance-manifest.json"
56
- data = load_manifest(manifest)
57
- validate_execution_baseline(manifest, data)
58
- bound_task = (manifest.parent / str(data.get("task_file", ""))).resolve()
59
- if {task.resolve() for task, _, _ in tasks} != {bound_task}:
60
- return {"decision": "block", "reason": "acceptance_manifest_task_mismatch"}
61
- required = [row["id"] for row in data["scenarios"] if row.get("kind", "functional") != "e2e"
62
- and not verified_evidence(row)]
63
- if required:
64
- return {"decision": "block", "reason": "functional_or_manual_evidence_missing", "scenarios": required}
65
- result = run_manifest(str(manifest), root, write_evidence=True, only_e2e=True)
66
- if result["decision"] == "pass":
140
+ if not targets and not manifest.exists():
141
+ return {"decision": "pass", "executed": 0, "reused": 0, "failed": [], "reason": "nothing_to_verify"}
142
+ review = _run_review_targets(root, targets) if targets else {"executed": 0, "reused": 0, "failed": []}
143
+ acceptance: dict[str, object] = {"decision": "pass", "results": []}
144
+ if manifest.exists():
145
+ data = load_manifest(manifest)
146
+ validate_execution_baseline(manifest, data)
147
+ bound_task = (manifest.parent / str(data.get("task_file", ""))).resolve()
148
+ if {task.resolve() for task, _, _ in tasks} != {bound_task}:
149
+ return {"decision": "block", "reason": "acceptance_manifest_task_mismatch"}
150
+ required = [row["id"] for row in data["scenarios"] if row.get("kind", "functional") != "e2e"
151
+ and not verified_evidence(row)]
152
+ if required:
153
+ return {"decision": "block", "reason": "functional_or_manual_evidence_missing", "scenarios": required}
154
+ acceptance = run_manifest(str(manifest), root, write_evidence=True, only_e2e=True)
155
+ decision = "pass" if not review["failed"] and acceptance.get("decision") == "pass" else "block"
156
+ result = {
157
+ **acceptance,
158
+ "decision": decision,
159
+ "executed": review["executed"],
160
+ "reused": review["reused"],
161
+ "failed": review["failed"],
162
+ "review": review,
163
+ }
164
+ if decision == "pass":
67
165
  for task_file, task_id, _ in tasks:
68
166
  _sync_markdown(str(task_file), task_id, "verified")
69
167
  return result