@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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-spec.md +13 -0
  3. package/src/adapters/claude/commands/cf-sync.md +25 -0
  4. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  5. package/src/adapters/claude/commands/cf-task/start.md +6 -7
  6. package/src/adapters/claude/commands/cf-task/verify-e2e.md +111 -0
  7. package/src/adapters/codex/skills/cf-spec/SKILL.md +1 -0
  8. package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
  9. package/src/adapters/codex/skills/cf-task-start/SKILL.md +6 -7
  10. package/src/adapters/costrict/commands/cf-spec.md +13 -0
  11. package/src/adapters/costrict/commands/cf-sync.md +25 -0
  12. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  13. package/src/adapters/costrict/commands/cf-task/start.md +6 -7
  14. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +111 -0
  15. package/src/adapters/opencode/commands/cf-spec.md +1 -0
  16. package/src/adapters/opencode/commands/cf-sync.md +29 -0
  17. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  18. package/src/adapters/opencode/commands/cf-task/start.md +6 -7
  19. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +111 -0
  20. package/src/adapters/opencode/plugins/code-flow/index.js +3 -3
  21. package/src/core/code-flow/config.yml +2 -1
  22. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +199 -0
  23. package/src/core/code-flow/scripts/cf_acceptance_runner.py +107 -0
  24. package/src/core/code-flow/scripts/cf_core.py +44 -1
  25. package/src/core/code-flow/scripts/cf_post_hook.py +5 -30
  26. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +41 -17
  27. package/src/core/code-flow/scripts/cf_session_state.py +37 -0
  28. package/src/core/code-flow/scripts/cf_spec_context.py +381 -29
  29. package/src/core/code-flow/scripts/cf_spec_gate.py +22 -6
  30. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  31. package/src/core/code-flow/scripts/cf_spec_resolver.py +78 -7
  32. package/src/core/code-flow/scripts/cf_spec_router.py +107 -10
  33. package/src/core/code-flow/scripts/cf_spec_session.py +5 -9
  34. package/src/core/code-flow/scripts/cf_spec_verify.py +166 -9
  35. package/src/core/code-flow/scripts/cf_stop_hook.py +35 -12
  36. package/src/core/code-flow/scripts/cf_sync.py +140 -0
  37. package/src/core/code-flow/scripts/cf_task_runtime.py +95 -7
  38. 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 refresh --task-dir ...`,执行 Start Gate;stale/pending/conflict 或依赖未闭合均不得继续。
69
- 2. 重新读取 refresh 后的 Context hash,再调用 `cf_spec_context.py active start`,传 task/context hash 和逐路径确认的 pre-existing ownership;已有/损坏 marker、未归属 diff 或 hash 不一致立即阻断。禁止先 start 再 refresh,避免 active marker 在编码前自行漂移。
70
- 3. 调用 `cf_spec_session.py`,只根据当前 TASK 的 `Spec-Refs`、Source 与 Acceptance Contract 覆盖写入 `_session/task-<name>.md`。禁止重新 catalog 或猜测规则。
71
- 4. 只有前三步全部成功,才用 Edit 更新子任务 Status 为 `in-progress`、追加 started log 并更新文件头日期。
72
- 5. 在修改任何生产代码前,为每个 Acceptance-Ref 填写测试文件、包含场景 ID 的测试用例名和可单独执行的命令
73
- 6. 先编写验收测试。E2E 测试必须经过契约声明的真实边界,不得用 mock 绕过 Store、Resolver、Builder、Renderer、Browser 等指定组件
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: 5000,
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