@michaelxwb/code-flow 0.6.0 → 0.6.2
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 +1 -1
- package/src/adapters/claude/commands/cf-spec.md +13 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +6 -7
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
- package/src/adapters/costrict/commands/cf-spec.md +13 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +6 -7
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/commands/cf-spec.md +1 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +6 -7
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
- package/src/core/code-flow/scripts/cf_core.py +44 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
- package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
- package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
- package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +106 -8
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: 从设计文档拆解需求,生成结构化任务文件
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
|
|
6
1
|
# cf-task:plan
|
|
7
2
|
|
|
8
3
|
从设计文档或设计简报拆解需求,生成结构化任务文件。
|
|
@@ -13,6 +8,8 @@ description: 从设计文档拆解需求,生成结构化任务文件
|
|
|
13
8
|
- `/cf-task:plan` — 交互式选择(从 `docs/` 目录列出候选)
|
|
14
9
|
- `/cf-task:plan <设计文档路径> --explore` — 仅输出分析报告,不生成文件
|
|
15
10
|
- `/cf-task:plan <设计文档路径> --quick` — 跳过缺口分析,直接拆解(保持原有行为)
|
|
11
|
+
- `/cf-task:plan <设计文档路径> --draft` — 仅生成任务草案,必须经过 `--verify-plan` 后才能启动
|
|
12
|
+
- `/cf-task:plan <任务文件> --verify-plan` — 执行完整 Acceptance Coverage 与 Spec Rule 门禁
|
|
16
13
|
- `/cf-task:plan <.design.md 路径>` — 从 cf-task:align 产出的设计简报拆解(自动跳过缺口分析)
|
|
17
14
|
|
|
18
15
|
## 执行步骤
|
|
@@ -62,6 +59,17 @@ Read 设计文档后,**先扫描文档结构**,建立章节索引表:
|
|
|
62
59
|
|
|
63
60
|
此索引用于后续步骤中精确记录每个子任务的来源章节。
|
|
64
61
|
|
|
62
|
+
### 2.4.1. Draft / Verify Plan 模式
|
|
63
|
+
|
|
64
|
+
`--draft` 只生成任务结构、依赖、Source 和初步验收映射,产物状态必须保持 `draft`,不得直接进入 `start`。用户确认拆分后执行 `--verify-plan`,完成全部场景覆盖、唯一 Rule owner、verifier、测试层级和真实边界校验;默认模式仍执行完整校验。
|
|
65
|
+
|
|
66
|
+
`--verify-plan` 的执行命令为:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python3 .code-flow/scripts/cf_acceptance_manifest.py \
|
|
70
|
+
--verify-plan --task-dir <需求目录> --task-file <任务文件>
|
|
71
|
+
```
|
|
72
|
+
|
|
65
73
|
### 2.5. Explore 模式(--explore)
|
|
66
74
|
|
|
67
75
|
如果用户传入 `--explore`,在建立章节索引后,输出以下分析报告,**不生成任务文件**:
|
|
@@ -303,6 +311,33 @@ TASK-002: <标题> [P1]
|
|
|
303
311
|
python3 .code-flow/scripts/cf_spec_gate.py --task-dir <需求目录> --stage plan --artifact <任务文件> --json
|
|
304
312
|
```
|
|
305
313
|
|
|
314
|
+
通过 Plan Gate 后,锁定验收基线:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
python3 .code-flow/scripts/cf_acceptance_manifest.py \
|
|
318
|
+
--task-file <任务文件> \
|
|
319
|
+
--output <需求目录>/.acceptance-manifest.json
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Manifest 锁定场景 ID、来源、测试层级、真实边界和责任 TASK。编码阶段不得静默修改这些字段;如发生范围变化,必须重新执行 plan 验证。
|
|
323
|
+
|
|
324
|
+
验收类型按测试层级归类:
|
|
325
|
+
- `unit/integration` → `functional`(Done Gate 自动执行)
|
|
326
|
+
- `E2E` → `e2e`(延迟至所有任务完成后,需 `--include-e2e` 显式执行)
|
|
327
|
+
- `manual` → `manual`(用户手动验证,永不自动执行)
|
|
328
|
+
|
|
329
|
+
E2E 场景依赖外部环境(数据库、API、浏览器),在编码阶段默认跳过(状态 `e2e_deferred`),不阻断 Done Gate。所有子任务完成后,执行:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
python3 .code-flow/scripts/cf_acceptance_runner.py \
|
|
333
|
+
--manifest <需求目录>/.acceptance-manifest.json \
|
|
334
|
+
--root . \
|
|
335
|
+
--include-e2e \
|
|
336
|
+
--write-evidence
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
不得为了提速降低 design 指定的 E2E 层级。
|
|
340
|
+
|
|
306
341
|
只有 Context Plan 状态与任务结构校验都 `decision=pass` 才输出 Start 下一步;缺唯一 owner、`verifier_ref`、测试层级或真实边界时必须回到拆解修复。
|
|
307
342
|
|
|
308
343
|
### 6. 输出摘要
|
|
@@ -65,13 +65,12 @@ description: 激活子任务并开始编码
|
|
|
65
65
|
|
|
66
66
|
在改状态或生产代码前,顺序固定且不得跳步:
|
|
67
67
|
|
|
68
|
-
1. 调用 `cf_spec_context.py
|
|
69
|
-
2.
|
|
70
|
-
3.
|
|
71
|
-
4.
|
|
72
|
-
5.
|
|
73
|
-
6.
|
|
74
|
-
7. 新功能或缺陷修复先执行一次测试并记录 RED:失败命令、失败用例和与预期缺陷对应的失败原因。纯重构或已有行为补测无法 RED 时,记录原因,不得伪造失败
|
|
68
|
+
1. 调用 `cf_spec_context.py start --task-dir ... --root ... --task ... --task-file ... --json`,由单个进程按 refresh → active start → session 顺序执行 Start Gate;stdin JSON 传入逐路径确认的 `owned_paths`。stale/pending/conflict、依赖未闭合、已有/损坏 marker、未归属 diff 或 hash 不一致立即阻断。禁止先 start 再 refresh,避免 active marker 在编码前自行漂移。
|
|
69
|
+
2. 从命令返回值读取 refresh 后的 Context hash、active 状态和 session 输出路径;该命令只根据当前 TASK 的 `Spec-Refs`、Source 与 Acceptance Contract 覆盖写入 `.code-flow/specs/_session/task-<name>.md`,禁止重新 catalog 或猜测规则。
|
|
70
|
+
3. 只有前两步全部成功,才用 Edit 更新子任务 Status 为 `in-progress`、追加 started log 并更新文件头日期。
|
|
71
|
+
4. 在修改任何生产代码前,为每个 Acceptance-Ref 填写测试文件、包含场景 ID 的测试用例名和可单独执行的命令
|
|
72
|
+
5. 先编写验收测试。E2E 测试必须经过契约声明的真实边界,不得用 mock 绕过 Store、Resolver、Builder、Renderer、Browser 等指定组件
|
|
73
|
+
6. 新功能或缺陷修复先执行一次测试并记录 RED:失败命令、失败用例和与预期缺陷对应的失败原因。纯重构或已有行为补测无法 RED 时,记录原因,不得伪造失败
|
|
75
74
|
|
|
76
75
|
RED 证据写入 `Acceptance Evidence`:
|
|
77
76
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Execute deferred E2E scenarios after all functional tests pass
|
|
3
|
+
globs: []
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# cf-task:verify-e2e
|
|
7
|
+
|
|
8
|
+
执行需求目录下被延迟的 E2E 验收场景。
|
|
9
|
+
|
|
10
|
+
## 使用场景
|
|
11
|
+
|
|
12
|
+
E2E 测试依赖外部环境(数据库、API、浏览器等),在编码阶段默认跳过(状态 `e2e_deferred`)。当所有子任务的 functional 测试通过后,使用此命令统一执行 E2E 验收。
|
|
13
|
+
|
|
14
|
+
## 调用方式
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
/cf-task:verify-e2e <需求目录>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 执行步骤
|
|
21
|
+
|
|
22
|
+
### 1. 检查前置条件
|
|
23
|
+
|
|
24
|
+
确认需求目录下所有子任务状态为 `done` 或 `verified`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
rg "^Status:" <需求目录>/*.md
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
如有 `in-progress` 或 `blocked` 任务,提示用户先完成。
|
|
31
|
+
|
|
32
|
+
### 2. 检查环境依赖
|
|
33
|
+
|
|
34
|
+
读取 `.acceptance-manifest.json` 中的 E2E 场景,提取所需的外部依赖(从 `boundary` 字段推断):
|
|
35
|
+
|
|
36
|
+
- `HTTP API` → 提示确认 API 服务运行中
|
|
37
|
+
- `Browser` → 提示确认浏览器驱动已安装
|
|
38
|
+
- `Database` → 提示确认测试数据库可访问
|
|
39
|
+
|
|
40
|
+
显示清单并询问:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
E2E 场景需要以下环境:
|
|
44
|
+
- 本地 API 服务 (http://localhost:3000)
|
|
45
|
+
- PostgreSQL 测试数据库
|
|
46
|
+
- Chrome WebDriver
|
|
47
|
+
|
|
48
|
+
环境已就绪?(y/N)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. 执行 E2E 场景
|
|
52
|
+
|
|
53
|
+
用户确认后,执行:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 .code-flow/scripts/cf_acceptance_runner.py \
|
|
57
|
+
--manifest <需求目录>/.acceptance-manifest.json \
|
|
58
|
+
--root . \
|
|
59
|
+
--include-e2e \
|
|
60
|
+
--write-evidence
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 4. 报告结果
|
|
64
|
+
|
|
65
|
+
解析执行结果:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"decision": "pass",
|
|
70
|
+
"results": [
|
|
71
|
+
{"id": "E-01", "kind": "e2e", "status": "passed"},
|
|
72
|
+
{"id": "E-02", "kind": "e2e", "status": "failed", "exit_code": 1}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `decision=pass`:所有 E2E 场景通过,提示"E2E 验收完成"
|
|
78
|
+
- `decision=block`:有失败场景,列出失败的场景 ID 和错误信息
|
|
79
|
+
|
|
80
|
+
### 5. 更新任务状态
|
|
81
|
+
|
|
82
|
+
所有 E2E 通过后,将需求目录下的任务状态更新为 `verified`(如果当前是 `done`)。
|
|
83
|
+
|
|
84
|
+
## 失败处理
|
|
85
|
+
|
|
86
|
+
E2E 失败时:
|
|
87
|
+
|
|
88
|
+
1. 显示失败场景的完整命令和退出码
|
|
89
|
+
2. 提示查看 `.acceptance-manifest.json` 中的 `evidence` 字段
|
|
90
|
+
3. 不自动修改任务状态,等待用户修复后重新执行
|
|
91
|
+
|
|
92
|
+
## 示例
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
/cf-task:verify-e2e .code-flow/tasks/2026-03-15/auth-module
|
|
96
|
+
|
|
97
|
+
> 检查子任务状态... 所有任务已完成
|
|
98
|
+
>
|
|
99
|
+
> E2E 场景需要以下环境:
|
|
100
|
+
> - 本地 API 服务 (http://localhost:3000)
|
|
101
|
+
> - PostgreSQL 测试数据库 (postgresql://localhost:5432/test)
|
|
102
|
+
>
|
|
103
|
+
> 环境已就绪?(y/N) y
|
|
104
|
+
>
|
|
105
|
+
> 执行 E2E 场景...
|
|
106
|
+
> ✓ E-01: 用户登录流程 (1.2s)
|
|
107
|
+
> ✓ E-02: 权限验证链路 (0.8s)
|
|
108
|
+
>
|
|
109
|
+
> E2E 验收完成!2/2 场景通过
|
|
110
|
+
> 已更新任务状态为 verified
|
|
111
|
+
```
|
|
@@ -21,13 +21,13 @@ function pythonPath(projectRoot, script) {
|
|
|
21
21
|
return join(projectRoot, SCRIPT_DIR, script);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function callHook(projectRoot, script, input) {
|
|
24
|
+
function callHook(projectRoot, script, input, timeout = 5000) {
|
|
25
25
|
try {
|
|
26
26
|
const proc = spawnSync("python3", [pythonPath(projectRoot, script)], {
|
|
27
27
|
cwd: projectRoot,
|
|
28
28
|
input: JSON.stringify(input),
|
|
29
29
|
encoding: "utf-8",
|
|
30
|
-
timeout
|
|
30
|
+
timeout,
|
|
31
31
|
maxBuffer: 1024 * 1024,
|
|
32
32
|
});
|
|
33
33
|
if (proc.error || proc.status !== 0) {
|
|
@@ -65,7 +65,7 @@ export const CodeFlow = async (ctx) => {
|
|
|
65
65
|
const sid =
|
|
66
66
|
input.event?.properties?.sessionID ||
|
|
67
67
|
input.event?.properties?.info?.id || "";
|
|
68
|
-
const result = callHook(projectRoot, "cf_stop_hook.py", { session_id: sid });
|
|
68
|
+
const result = callHook(projectRoot, "cf_stop_hook.py", { session_id: sid }, 35000);
|
|
69
69
|
if (result?.reason) {
|
|
70
70
|
// idle 无法阻断,校验失败排队到下一轮 system prompt
|
|
71
71
|
const pending = sessionContext.get(sid);
|
|
@@ -2,7 +2,7 @@ version: 1
|
|
|
2
2
|
|
|
3
3
|
spec_workflow:
|
|
4
4
|
schema_version: 1
|
|
5
|
-
enforcement: required
|
|
5
|
+
enforcement: required # required=全门禁 | warn=门禁仅记录不阻断 | inject=只注入不门禁
|
|
6
6
|
drift_check: true
|
|
7
7
|
scope_expansion_check: true
|
|
8
8
|
metrics: true
|
|
@@ -29,6 +29,7 @@ quality_loop:
|
|
|
29
29
|
# 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
|
|
30
30
|
# 缺省视为 true;仅显式 false 关闭
|
|
31
31
|
compress: true
|
|
32
|
+
compress_reminder: true # 会话过长每 N 轮提醒 /compact(per-session 计数,缺省 true)
|
|
32
33
|
code_extensions:
|
|
33
34
|
- ".py"
|
|
34
35
|
- ".ts"
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lock and validate plan Acceptance Coverage entries."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _kind(level: str) -> str:
|
|
19
|
+
"""Map test level to execution kind.
|
|
20
|
+
|
|
21
|
+
- functional: auto-executed in Done Gate (unit, integration)
|
|
22
|
+
- e2e: deferred until explicit --verify-e2e (needs external dependencies)
|
|
23
|
+
- manual: user confirmation required, never auto-executed
|
|
24
|
+
"""
|
|
25
|
+
normalized = level.strip().lower()
|
|
26
|
+
if normalized == "manual":
|
|
27
|
+
return "manual"
|
|
28
|
+
if normalized == "e2e":
|
|
29
|
+
return "e2e" # Keep as separate kind, not functional
|
|
30
|
+
return "functional"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _manifest_hash(rows: list[dict[str, str]]) -> str:
|
|
34
|
+
immutable = [{key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner")} for item in rows]
|
|
35
|
+
payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
36
|
+
return hashlib.sha256(payload).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_manifest(task_file: str) -> dict[str, object]:
|
|
40
|
+
path = Path(task_file)
|
|
41
|
+
text = path.read_text(encoding="utf-8")
|
|
42
|
+
rows: list[dict[str, str]] = []
|
|
43
|
+
in_table = False
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
if line.startswith("## Acceptance Coverage"):
|
|
47
|
+
in_table = True
|
|
48
|
+
continue
|
|
49
|
+
if in_table and line.startswith("## "):
|
|
50
|
+
break
|
|
51
|
+
if not in_table:
|
|
52
|
+
continue
|
|
53
|
+
match = _ROW_RE.match(line)
|
|
54
|
+
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
55
|
+
continue
|
|
56
|
+
fields = [item.strip() for item in match.group(2).split("|")]
|
|
57
|
+
if len(fields) < 5 or match.group(1) in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(match.group(1))
|
|
60
|
+
rows.append({"id": match.group(1), "source": fields[0], "level": fields[1], "kind": _kind(fields[1]), "boundary": fields[2], "owner": fields[3], "status": fields[4]})
|
|
61
|
+
if not rows:
|
|
62
|
+
raise ValueError("Acceptance Coverage 缺失或为空")
|
|
63
|
+
return {"schema": 1, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
67
|
+
manifest = extract_manifest(task_file)
|
|
68
|
+
target = Path(output)
|
|
69
|
+
try:
|
|
70
|
+
manifest["task_file"] = str(Path(task_file).resolve().relative_to(target.parent.resolve()))
|
|
71
|
+
except ValueError:
|
|
72
|
+
manifest["task_file"] = str(Path(task_file).resolve())
|
|
73
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
75
|
+
return manifest
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
|
|
79
|
+
try:
|
|
80
|
+
manifest = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
|
|
81
|
+
expected = extract_manifest(task_file)
|
|
82
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
83
|
+
return False, f"acceptance_manifest_invalid: {exc}"
|
|
84
|
+
if manifest.get("schema") != 1 or manifest.get("task_sha256") != expected["task_sha256"]:
|
|
85
|
+
return False, "acceptance_manifest_drift"
|
|
86
|
+
actual = {item.get("id") for item in manifest.get("scenarios", []) if isinstance(item, Mapping)}
|
|
87
|
+
required = {item["id"] for item in expected["scenarios"]}
|
|
88
|
+
if actual != required:
|
|
89
|
+
return False, "acceptance_manifest_scenarios_changed"
|
|
90
|
+
text = Path(task_file).read_text(encoding="utf-8")
|
|
91
|
+
for scenario in expected["scenarios"]:
|
|
92
|
+
owner = scenario["owner"]
|
|
93
|
+
section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
94
|
+
refs = section.group(0).partition("Acceptance-Refs")[2] if section else ""
|
|
95
|
+
if scenario["id"] not in refs:
|
|
96
|
+
return False, "acceptance_manifest_owner_missing"
|
|
97
|
+
return True, ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: str, evidence: str) -> None:
|
|
101
|
+
if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
|
|
102
|
+
raise ValueError("manual evidence must be confirmed by a user")
|
|
103
|
+
path = Path(manifest_file)
|
|
104
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
105
|
+
scenarios = data.get("scenarios")
|
|
106
|
+
if not isinstance(scenarios, list):
|
|
107
|
+
raise ValueError("invalid manifest scenarios")
|
|
108
|
+
found = False
|
|
109
|
+
owner = ""
|
|
110
|
+
task_file = data.get("task_file")
|
|
111
|
+
for item in scenarios:
|
|
112
|
+
if isinstance(item, dict) and item.get("id") == scenario_id:
|
|
113
|
+
if item.get("kind") != "manual":
|
|
114
|
+
raise ValueError("scenario is not manual")
|
|
115
|
+
item["status"] = "verified"
|
|
116
|
+
item["evidence"] = {"confirmed_by": confirmed_by, "evidence": evidence}
|
|
117
|
+
found = True
|
|
118
|
+
owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
|
|
119
|
+
if not found:
|
|
120
|
+
raise ValueError("scenario not found")
|
|
121
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
122
|
+
if isinstance(task_file, str) and task_file:
|
|
123
|
+
task_path = Path(task_file)
|
|
124
|
+
if not task_path.is_absolute():
|
|
125
|
+
task_path = path.parent / task_path
|
|
126
|
+
_sync_task_evidence(task_path, scenario_id, confirmed_by, evidence, owner)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sync_task_evidence(task_file: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "") -> None:
|
|
130
|
+
text = task_file.read_text(encoding="utf-8")
|
|
131
|
+
if owner:
|
|
132
|
+
section_match = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
133
|
+
else:
|
|
134
|
+
section_match = re.search(r"(?ms)^##\s+TASK-\d+:.*?(?=^##\s+TASK-|\Z)", text)
|
|
135
|
+
if section_match is None:
|
|
136
|
+
return
|
|
137
|
+
section = section_match.group(0)
|
|
138
|
+
line = f"- {scenario_id}: verified — {evidence} (confirmed_by: {confirmed_by})"
|
|
139
|
+
contract_match = re.search(r"(?ms)^### Acceptance Contract\s*$\n(.*?)(?=^### |^## |\Z)", section)
|
|
140
|
+
if contract_match and scenario_id in contract_match.group(1) and "verified" not in contract_match.group(1).lower():
|
|
141
|
+
body = contract_match.group(1)
|
|
142
|
+
body = "\n".join((f"{item} — verified" if scenario_id in item else item) for item in body.splitlines())
|
|
143
|
+
section = section[:contract_match.start(1)] + body + section[contract_match.end(1):]
|
|
144
|
+
evidence_match = re.search(r"(?ms)^### Acceptance Evidence\s*$\n(.*?)(?=^### |^## |\Z)", section)
|
|
145
|
+
if evidence_match:
|
|
146
|
+
body = evidence_match.group(1)
|
|
147
|
+
replacement = body.rstrip() + "\n" + line + "\n"
|
|
148
|
+
section = section[:evidence_match.start(1)] + replacement + section[evidence_match.end(1):]
|
|
149
|
+
else:
|
|
150
|
+
section = section.rstrip() + "\n\n### Acceptance Evidence\n" + line + "\n"
|
|
151
|
+
task_file.write_text(text[:section_match.start()] + section + text[section_match.end():], encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
155
|
+
parser = argparse.ArgumentParser()
|
|
156
|
+
parser.add_argument("--task-file", required=True)
|
|
157
|
+
parser.add_argument("--output", default="")
|
|
158
|
+
parser.add_argument("--verify-plan", action="store_true")
|
|
159
|
+
parser.add_argument("--task-dir", default="")
|
|
160
|
+
parser.add_argument("--record-manual", action="store_true")
|
|
161
|
+
parser.add_argument("--scenario-id", default="")
|
|
162
|
+
parser.add_argument("--confirmed-by", default="")
|
|
163
|
+
parser.add_argument("--evidence", default="")
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
try:
|
|
166
|
+
if args.record_manual:
|
|
167
|
+
if not args.output or not args.scenario_id or not args.evidence:
|
|
168
|
+
raise ValueError("--record-manual requires --output, --scenario-id and --evidence")
|
|
169
|
+
record_manual_evidence(args.output, args.scenario_id, args.confirmed_by, args.evidence)
|
|
170
|
+
stdout.write(json.dumps({"ok": True, "scenario": args.scenario_id}, ensure_ascii=False))
|
|
171
|
+
return 0
|
|
172
|
+
if args.verify_plan:
|
|
173
|
+
if not args.task_dir:
|
|
174
|
+
raise ValueError("--verify-plan requires --task-dir")
|
|
175
|
+
from cf_spec_context import load_context
|
|
176
|
+
from cf_spec_gate import result_to_data, validate_plan_coverage, validate_stage
|
|
177
|
+
|
|
178
|
+
context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
|
|
179
|
+
gate = validate_stage(context, "plan")
|
|
180
|
+
coverage = validate_plan_coverage(context, args.task_file)
|
|
181
|
+
result = {
|
|
182
|
+
"decision": "block" if gate.decision == "block" or coverage.decision == "block" else "pass",
|
|
183
|
+
"gate": result_to_data(gate),
|
|
184
|
+
"coverage": result_to_data(coverage),
|
|
185
|
+
}
|
|
186
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
187
|
+
return 0 if result["decision"] == "pass" else 3
|
|
188
|
+
if not args.output:
|
|
189
|
+
raise ValueError("--output is required unless --verify-plan is used")
|
|
190
|
+
manifest = write_manifest(args.task_file, args.output)
|
|
191
|
+
stdout.write(json.dumps({"ok": True, "scenarios": len(manifest["scenarios"])}, ensure_ascii=False))
|
|
192
|
+
return 0
|
|
193
|
+
except (OSError, ValueError) as exc:
|
|
194
|
+
stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
195
|
+
return 2
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Execute manifest scenarios with deterministic ordering and evidence."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(item: Mapping[str, object], root: Path, include_e2e: bool = False) -> dict[str, object]:
|
|
15
|
+
scenario_id = str(item.get("id", "unknown"))
|
|
16
|
+
kind = str(item.get("kind", "functional"))
|
|
17
|
+
if kind == "manual":
|
|
18
|
+
return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
|
|
19
|
+
if kind == "e2e" and not include_e2e:
|
|
20
|
+
return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
|
|
21
|
+
command = item.get("command")
|
|
22
|
+
if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
|
|
23
|
+
return {"id": scenario_id, "kind": kind, "status": "not_configured"}
|
|
24
|
+
timeout = item.get("timeout", 60)
|
|
25
|
+
timeout_value = float(timeout) if isinstance(timeout, (int, float)) else 60.0
|
|
26
|
+
cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(command, cwd=str(root / cwd), capture_output=True, timeout=timeout_value, check=False)
|
|
29
|
+
except subprocess.TimeoutExpired:
|
|
30
|
+
return {"id": scenario_id, "kind": kind, "status": "failed", "error": "timeout"}
|
|
31
|
+
except OSError as exc:
|
|
32
|
+
return {"id": scenario_id, "kind": kind, "status": "failed", "error": str(exc)}
|
|
33
|
+
return {"id": scenario_id, "kind": kind, "status": "passed" if result.returncode == 0 else "failed", "exit_code": result.returncode}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
|
|
37
|
+
by_id = {str(item.get("id")): item for item in items}
|
|
38
|
+
pending = set(by_id)
|
|
39
|
+
ordered: list[Mapping[str, object]] = []
|
|
40
|
+
while pending:
|
|
41
|
+
ready: list[str] = []
|
|
42
|
+
for item_id in by_id:
|
|
43
|
+
if item_id not in pending:
|
|
44
|
+
continue
|
|
45
|
+
deps = by_id[item_id].get("depends_on", [])
|
|
46
|
+
dep_ids = [str(dep) for dep in deps] if isinstance(deps, list) else []
|
|
47
|
+
unknown = [dep for dep in dep_ids if dep not in by_id]
|
|
48
|
+
if unknown:
|
|
49
|
+
raise ValueError(f"acceptance scenario dependency missing: {', '.join(unknown)}")
|
|
50
|
+
if all(dep not in pending for dep in dep_ids):
|
|
51
|
+
ready.append(item_id)
|
|
52
|
+
if not ready:
|
|
53
|
+
raise ValueError("acceptance scenario dependency cycle")
|
|
54
|
+
ordered.extend(by_id[item_id] for item_id in ready)
|
|
55
|
+
pending.difference_update(ready)
|
|
56
|
+
return ordered
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_manifest(manifest_file: str, root: str, write_evidence: bool = False, include_e2e: bool = False) -> dict[str, object]:
|
|
60
|
+
data = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
|
|
61
|
+
scenarios = data.get("scenarios", [])
|
|
62
|
+
if not isinstance(scenarios, list):
|
|
63
|
+
raise ValueError("invalid scenarios")
|
|
64
|
+
items = [item for item in scenarios if isinstance(item, Mapping)]
|
|
65
|
+
ordered = _ordered(items)
|
|
66
|
+
results = [_run(item, Path(root), include_e2e) for item in ordered]
|
|
67
|
+
configured = any(isinstance(item.get("command"), list) for item in items)
|
|
68
|
+
allowed = ("passed", "manual_pending", "e2e_deferred", "not_configured") if not configured else ("passed", "manual_pending", "e2e_deferred")
|
|
69
|
+
decision = "pass" if all(item["status"] in allowed for item in results) else "block"
|
|
70
|
+
if write_evidence:
|
|
71
|
+
for item, result in zip(ordered, results):
|
|
72
|
+
if isinstance(item, dict) and result["status"] == "passed":
|
|
73
|
+
item["status"] = "verified"
|
|
74
|
+
item["evidence"] = result
|
|
75
|
+
Path(manifest_file).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
76
|
+
task_file = data.get("task_file")
|
|
77
|
+
if isinstance(task_file, str) and task_file:
|
|
78
|
+
from cf_acceptance_manifest import _sync_task_evidence
|
|
79
|
+
|
|
80
|
+
task_path = Path(task_file)
|
|
81
|
+
if not task_path.is_absolute():
|
|
82
|
+
task_path = Path(manifest_file).parent / task_path
|
|
83
|
+
for item, result in zip(ordered, results):
|
|
84
|
+
if result["status"] == "passed":
|
|
85
|
+
owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
|
|
86
|
+
_sync_task_evidence(task_path, str(result["id"]), "runner", "automated command passed", owner)
|
|
87
|
+
return {"decision": decision, "results": results}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
91
|
+
parser = argparse.ArgumentParser()
|
|
92
|
+
parser.add_argument("--manifest", required=True)
|
|
93
|
+
parser.add_argument("--root", required=True)
|
|
94
|
+
parser.add_argument("--write-evidence", action="store_true")
|
|
95
|
+
parser.add_argument("--include-e2e", action="store_true", help="Execute E2E scenarios (deferred by default)")
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
try:
|
|
98
|
+
result = run_manifest(args.manifest, args.root, args.write_evidence, args.include_e2e)
|
|
99
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
100
|
+
return 0 if result["decision"] == "pass" else 3
|
|
101
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
102
|
+
stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -3,6 +3,7 @@ import fnmatch
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
|
+
import time
|
|
6
7
|
|
|
7
8
|
# --- Config cache (fix #3: avoid re-parsing YAML on every hook call) ---
|
|
8
9
|
|
|
@@ -467,7 +468,8 @@ def resolve_quality_loop(config: dict) -> dict:
|
|
|
467
468
|
"""
|
|
468
469
|
if not isinstance(config, dict):
|
|
469
470
|
return {"enabled": False, "post_check": False,
|
|
470
|
-
"stop_check": False, "correction_capture": False
|
|
471
|
+
"stop_check": False, "correction_capture": False,
|
|
472
|
+
"compress_reminder": False}
|
|
471
473
|
cfg = config.get("quality_loop")
|
|
472
474
|
cfg = cfg if isinstance(cfg, dict) else {}
|
|
473
475
|
enabled = cfg.get("enabled") is True
|
|
@@ -480,9 +482,31 @@ def resolve_quality_loop(config: dict) -> dict:
|
|
|
480
482
|
"post_check": _sub("post_check"),
|
|
481
483
|
"stop_check": _sub("stop_check"),
|
|
482
484
|
"correction_capture": _sub("correction_capture"),
|
|
485
|
+
"compress_reminder": _sub("compress_reminder"),
|
|
483
486
|
}
|
|
484
487
|
|
|
485
488
|
|
|
489
|
+
ENFORCEMENT_MODES = frozenset(("required", "warn", "inject"))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def resolve_enforcement(config: dict) -> str:
|
|
493
|
+
"""Project-wide Spec Workflow enforcement tier (light-mode ladder).
|
|
494
|
+
|
|
495
|
+
required — full fail-closed gates (default; upgraded projects keep it);
|
|
496
|
+
warn — gates run but only log/report, never block the session;
|
|
497
|
+
inject — routing/injection only; gates and blocks are disabled.
|
|
498
|
+
Missing or invalid values fail closed to "required".
|
|
499
|
+
"""
|
|
500
|
+
if not isinstance(config, dict):
|
|
501
|
+
return "required"
|
|
502
|
+
workflow = config.get("spec_workflow")
|
|
503
|
+
workflow = workflow if isinstance(workflow, dict) else {}
|
|
504
|
+
value = workflow.get("enforcement")
|
|
505
|
+
if isinstance(value, str) and value in ENFORCEMENT_MODES:
|
|
506
|
+
return value
|
|
507
|
+
return "required"
|
|
508
|
+
|
|
509
|
+
|
|
486
510
|
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
|
|
487
511
|
|
|
488
512
|
|
|
@@ -642,6 +666,25 @@ def _log(msg: str) -> None:
|
|
|
642
666
|
print(msg, file=sys.stderr)
|
|
643
667
|
|
|
644
668
|
|
|
669
|
+
_PROCESS_START = time.monotonic()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def timing_log(name: str) -> None:
|
|
673
|
+
"""Emit process-elapsed ms to stderr only when CF_DEBUG=1 (diagnostics).
|
|
674
|
+
|
|
675
|
+
Never touches stdout, so the hook JSON protocol is unaffected; a default
|
|
676
|
+
run pays one env lookup and no IO.
|
|
677
|
+
"""
|
|
678
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
679
|
+
_log(f"{name}: {int((time.monotonic() - _PROCESS_START) * 1000)}ms")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def phase_timing(name: str, started: float) -> None:
|
|
683
|
+
"""Emit a named phase duration to stderr when CF_DEBUG=1."""
|
|
684
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
685
|
+
_log(f"cf_phase {name}: {int((time.monotonic() - started) * 1000)}ms")
|
|
686
|
+
|
|
687
|
+
|
|
645
688
|
def ensure_utf8_io() -> None:
|
|
646
689
|
"""Force stdin/stdout/stderr to UTF-8 so Windows hooks don't mojibake.
|
|
647
690
|
|