@michaelxwb/code-flow 0.6.1 → 0.6.3
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-task/archive.md +5 -5
- package/src/adapters/claude/commands/cf-task/block.md +15 -9
- package/src/adapters/claude/commands/cf-task/graph.md +8 -1
- package/src/adapters/claude/commands/cf-task/note.md +1 -4
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +22 -22
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/claude/commands/cf-validate.md +12 -13
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
- package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
- package/src/adapters/costrict/commands/cf-task/block.md +15 -9
- package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
- package/src/adapters/costrict/commands/cf-task/note.md +1 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +22 -22
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/costrict/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
- package/src/adapters/opencode/commands/cf-task/block.md +15 -9
- package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
- package/src/adapters/opencode/commands/cf-task/note.md +1 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +22 -22
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/opencode/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
- package/src/cli.js +177 -63
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +26 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
- package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
- package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
- package/src/core/code-flow/scripts/cf_task_state.py +3 -0
- package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
- package/src/core/code-flow/scripts/cf_validation.py +81 -0
- package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
|
@@ -12,6 +12,7 @@ from cf_core import (
|
|
|
12
12
|
compress_content,
|
|
13
13
|
estimate_tokens,
|
|
14
14
|
load_config,
|
|
15
|
+
project_instruction_file,
|
|
15
16
|
non_injectable_specs,
|
|
16
17
|
resolve_quality_loop,
|
|
17
18
|
)
|
|
@@ -21,6 +22,7 @@ def _violation_fixed(index: int, events: list) -> bool:
|
|
|
21
22
|
"""修正口径:违规后同会话同文件有后续编辑,且其后无同 check 再违规。
|
|
22
23
|
|
|
23
24
|
用日志追加顺序判先后(ts 仅秒级精度,同秒事件无法靠时间戳排序)。
|
|
25
|
+
保留单点查询语义;批量聚合请用 violation_fixed_batch(一次线性扫描)。
|
|
24
26
|
"""
|
|
25
27
|
violation = events[index]
|
|
26
28
|
v_data = violation.get("data") or {}
|
|
@@ -41,6 +43,37 @@ def _violation_fixed(index: int, events: list) -> bool:
|
|
|
41
43
|
return later_edit
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
def violation_fixed_batch(events: list) -> list[bool]:
|
|
47
|
+
"""Batch twin of _violation_fixed: one reverse pass, identical semantics.
|
|
48
|
+
|
|
49
|
+
Segments the timeline per (session, file) at edits: a violation is fixed
|
|
50
|
+
iff an edit exists after it and no same-check violation exists in a later
|
|
51
|
+
segment. No slicing, no per-violation rescan: O(n) time.
|
|
52
|
+
"""
|
|
53
|
+
fixed: dict[int, bool] = {}
|
|
54
|
+
edit_right: set[tuple[str, str]] = set()
|
|
55
|
+
current: dict[tuple[str, str], set[str]] = {}
|
|
56
|
+
later: dict[tuple[str, str], set[str]] = {}
|
|
57
|
+
for index in range(len(events) - 1, -1, -1):
|
|
58
|
+
event = events[index]
|
|
59
|
+
if not isinstance(event, dict):
|
|
60
|
+
continue
|
|
61
|
+
kind = event.get("event")
|
|
62
|
+
data = event.get("data")
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
data = {}
|
|
65
|
+
key = (str(event.get("sid")), str(data.get("file")))
|
|
66
|
+
if kind == "edit":
|
|
67
|
+
later.setdefault(key, set()).update(current.get(key, ()))
|
|
68
|
+
current[key] = set()
|
|
69
|
+
edit_right.add(key)
|
|
70
|
+
elif kind == "violation":
|
|
71
|
+
check = str(data.get("check_id"))
|
|
72
|
+
fixed[index] = key in edit_right and check not in later.get(key, ())
|
|
73
|
+
current.setdefault(key, set()).add(check)
|
|
74
|
+
return [fixed[index] for index, event in enumerate(events) if isinstance(event, dict) and event.get("event") == "violation"]
|
|
75
|
+
|
|
76
|
+
|
|
44
77
|
def quality_loop_summary(project_root: str, config: dict) -> dict:
|
|
45
78
|
"""FEAT-05/02 度量聚合:Top 违规榜 / 修正率 / 误报与停用 / 降级组件。"""
|
|
46
79
|
switches = resolve_quality_loop(config)
|
|
@@ -61,13 +94,10 @@ def quality_loop_summary(project_root: str, config: dict) -> dict:
|
|
|
61
94
|
key=lambda item: -item["count"],
|
|
62
95
|
)[:10]
|
|
63
96
|
|
|
64
|
-
fixed =
|
|
65
|
-
1 for i, event in enumerate(events)
|
|
66
|
-
if event.get("event") == "violation" and _violation_fixed(i, events)
|
|
67
|
-
)
|
|
97
|
+
fixed = violation_fixed_batch(events)
|
|
68
98
|
summary["violation_total"] = len(violations)
|
|
69
99
|
summary["fix_rate"] = (
|
|
70
|
-
f"{round(fixed * 100 / len(violations))}%" if violations else "n/a"
|
|
100
|
+
f"{round(sum(fixed) * 100 / len(violations))}%" if violations else "n/a"
|
|
71
101
|
)
|
|
72
102
|
|
|
73
103
|
state = load_check_state(project_root)
|
|
@@ -273,7 +303,9 @@ def main() -> None:
|
|
|
273
303
|
except Exception:
|
|
274
304
|
total_budget = l0_budget + l1_budget
|
|
275
305
|
|
|
276
|
-
|
|
306
|
+
platform = next((arg.split("=", 1)[1] for arg in sys.argv if arg.startswith("--platform=")), "")
|
|
307
|
+
instruction_file = project_instruction_file(project_root, platform)
|
|
308
|
+
claude_path = os.path.join(project_root, instruction_file)
|
|
277
309
|
l0_tokens = 0
|
|
278
310
|
if os.path.exists(claude_path):
|
|
279
311
|
l0_tokens = estimate_tokens(read_text(claude_path))
|
|
@@ -374,7 +406,7 @@ def main() -> None:
|
|
|
374
406
|
workflow_summary = spec_workflow_summary(project_root)
|
|
375
407
|
|
|
376
408
|
output = {
|
|
377
|
-
"l0": {"file":
|
|
409
|
+
"l0": {"file": instruction_file, "tokens": l0_tokens, "budget": l0_budget},
|
|
378
410
|
"l1": l1,
|
|
379
411
|
"total_tokens": total_tokens,
|
|
380
412
|
"total_budget": total_budget,
|
|
@@ -394,7 +426,7 @@ def main() -> None:
|
|
|
394
426
|
}
|
|
395
427
|
if audit_mode:
|
|
396
428
|
from cf_scan import build_report
|
|
397
|
-
scan = build_report(project_root)
|
|
429
|
+
scan = build_report(project_root, platform)
|
|
398
430
|
output["audit"] = {
|
|
399
431
|
"files": [e for e in scan["files"] if e.get("issues")],
|
|
400
432
|
"review": scan["review"],
|
|
@@ -404,7 +436,7 @@ def main() -> None:
|
|
|
404
436
|
print(json.dumps(output, ensure_ascii=False))
|
|
405
437
|
return
|
|
406
438
|
|
|
407
|
-
print("L0 (
|
|
439
|
+
print(f"L0 ({instruction_file}):", f"{l0_tokens} / {l0_budget}")
|
|
408
440
|
for domain, items in l1.items():
|
|
409
441
|
total_domain = sum(i["tokens"] for i in items if i.get("injectable", True))
|
|
410
442
|
print(f"L1 {domain}:", total_domain)
|
|
@@ -12,14 +12,17 @@ continued session is never re-blocked (no loops). All-pass → silent exit 0.
|
|
|
12
12
|
"""
|
|
13
13
|
import fnmatch
|
|
14
14
|
import json
|
|
15
|
+
import math
|
|
15
16
|
import os
|
|
16
17
|
import re
|
|
17
|
-
import subprocess
|
|
18
18
|
import sys
|
|
19
|
+
import time
|
|
19
20
|
|
|
20
21
|
import cf_log
|
|
22
|
+
from cf_exec_base import build_argv, remaining_seconds, run_command, execution_session
|
|
21
23
|
from cf_spec_context import load_active_task
|
|
22
24
|
from cf_task_runtime import run_done_gate
|
|
25
|
+
from cf_acceptance_evidence import line_status, scenario_line
|
|
23
26
|
from cf_core import (
|
|
24
27
|
_log,
|
|
25
28
|
ensure_utf8_io,
|
|
@@ -28,6 +31,7 @@ from cf_core import (
|
|
|
28
31
|
resolve_enforcement,
|
|
29
32
|
resolve_quality_loop,
|
|
30
33
|
resolve_session_id,
|
|
34
|
+
timing_log,
|
|
31
35
|
)
|
|
32
36
|
|
|
33
37
|
TOTAL_BUDGET_SECONDS = 30.0
|
|
@@ -109,7 +113,9 @@ def _subsection(section: str, heading: str) -> str:
|
|
|
109
113
|
|
|
110
114
|
def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
|
|
111
115
|
status = re.search(r"(?m)^- \*\*Status\*\*: ([^\n]+)", section)
|
|
112
|
-
|
|
116
|
+
# done = implementation finished; verified = E2E/final acceptance closed.
|
|
117
|
+
# Both enter the same contract checks; anything else is still in flight.
|
|
118
|
+
if not status or status.group(1).strip() not in ("done", "verified"):
|
|
113
119
|
return ""
|
|
114
120
|
refs_match = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*: ([^\n]+)", section)
|
|
115
121
|
if not refs_match:
|
|
@@ -124,17 +130,17 @@ def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
|
|
|
124
130
|
evidence = _subsection(section, "Acceptance Evidence")
|
|
125
131
|
if not contract or not evidence:
|
|
126
132
|
return f"{task_id} 缺少 Acceptance Contract 或 Acceptance Evidence"
|
|
127
|
-
if _UNVERIFIED_RE.search(contract) or _UNVERIFIED_RE.search(evidence):
|
|
128
|
-
return f"{task_id} 验收契约仍有 planned/pending/TBD"
|
|
129
133
|
for scenario in scenarios:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
coverage_rows = [line for line in coverage.splitlines() if scenario_line(line, scenario)]
|
|
135
|
+
cells = coverage_rows[0].strip().strip("|").split("|") if coverage_rows else []
|
|
136
|
+
deferred = status.group(1).strip() == "done" and len(cells) >= 3 and cells[2].strip().lower() == "e2e"
|
|
137
|
+
allowed = {"verified", "e2e_deferred"} if deferred else {"verified"}
|
|
138
|
+
states = []
|
|
139
|
+
for body in (contract, evidence, coverage):
|
|
140
|
+
found = [line_status(line, scenario) for line in body.splitlines() if scenario_line(line, scenario)]
|
|
141
|
+
states.append(found[-1] if found else "")
|
|
142
|
+
if not all(state in allowed for state in states):
|
|
143
|
+
return f"{task_id} 的 {scenario} 状态未闭环(planned/pending/TBD/failed 或缺少 verified;仅实现阶段 E2E 可 e2e_deferred)"
|
|
138
144
|
return ""
|
|
139
145
|
|
|
140
146
|
|
|
@@ -165,70 +171,92 @@ def task_acceptance_failures(project_root: str, files: list) -> list:
|
|
|
165
171
|
return failures
|
|
166
172
|
|
|
167
173
|
|
|
174
|
+
def _validator_failure(validator: dict, detail: str, incomplete: bool = False) -> dict:
|
|
175
|
+
result = {"name": validator.get("name", "validator"),
|
|
176
|
+
"on_fail": validator.get("on_fail", ""), "detail": detail}
|
|
177
|
+
if incomplete:
|
|
178
|
+
result["incomplete"] = True
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _validator_argv(root: str, validator: dict, matched: list, strict: bool) -> list:
|
|
183
|
+
existing = [name for name in matched if os.path.isfile(os.path.join(root, name))]
|
|
184
|
+
template = str(validator.get("command", ""))
|
|
185
|
+
if "{files}" in template and not existing:
|
|
186
|
+
return []
|
|
187
|
+
argv = build_argv(template, existing)
|
|
188
|
+
if not argv and strict:
|
|
189
|
+
raise ValueError("validator command missing")
|
|
190
|
+
return argv
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _validator_result(root: str, validator: dict, argv: list, sid: str,
|
|
194
|
+
deadline: float, strict: bool) -> tuple[list, bool]:
|
|
195
|
+
try:
|
|
196
|
+
timeout = float(validator.get("timeout", 30000)) / 1000.0
|
|
197
|
+
except (ValueError, TypeError):
|
|
198
|
+
if strict:
|
|
199
|
+
return [_validator_failure(validator, "invalid validator timeout")], False
|
|
200
|
+
timeout = 30.0
|
|
201
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
202
|
+
return [_validator_failure(validator, "invalid validator timeout")], False
|
|
203
|
+
outcome = run_command(argv, root, timeout, deadline)
|
|
204
|
+
passed = outcome["status"] == "ok" and outcome["returncode"] == 0
|
|
205
|
+
if outcome["status"] == "deadline_exceeded":
|
|
206
|
+
return [_validator_failure(validator, "预算耗尽未执行", True)], True
|
|
207
|
+
timed_out = outcome["status"] == "timeout"
|
|
208
|
+
detail = (f"超时(>{timeout:.0f}s),未完成" if timed_out else
|
|
209
|
+
(str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-400:])
|
|
210
|
+
if outcome["status"] == "spawn_error":
|
|
211
|
+
cf_log.degrade(root, "stop_check", f"{validator.get('name')}:{outcome['stderr']}", sid)
|
|
212
|
+
if not strict:
|
|
213
|
+
return [], False
|
|
214
|
+
cf_log.append_event(root, "stop_check",
|
|
215
|
+
{"trigger": validator.get("trigger", ""),
|
|
216
|
+
"cmd": str(validator.get("command", ""))[:120], "passed": passed}, sid)
|
|
217
|
+
return ([] if passed else [_validator_failure(validator, detail)]), timed_out
|
|
218
|
+
|
|
219
|
+
|
|
168
220
|
def run_validators(
|
|
169
221
|
project_root: str, validators: list, files: list, sid: str,
|
|
170
222
|
total_budget: float = TOTAL_BUDGET_SECONDS,
|
|
223
|
+
deadline: float = 0.0,
|
|
224
|
+
strict: bool = False,
|
|
171
225
|
) -> tuple:
|
|
172
|
-
"""
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
truncated = False
|
|
176
|
-
deadline = time.monotonic() + total_budget
|
|
226
|
+
"""Match validators, execute argv under one deadline, report unrun work."""
|
|
227
|
+
failures, truncated = [], False
|
|
228
|
+
deadline = deadline or time.monotonic() + total_budget
|
|
177
229
|
for validator in validators:
|
|
178
230
|
if not isinstance(validator, dict):
|
|
231
|
+
if strict:
|
|
232
|
+
failures.append(_validator_failure({}, "invalid validator record"))
|
|
179
233
|
continue
|
|
180
234
|
matched = [f for f in files if trigger_matches(validator.get("trigger", ""), f)]
|
|
181
235
|
if not matched:
|
|
182
236
|
continue
|
|
183
|
-
remaining = deadline
|
|
184
|
-
if remaining <= 0:
|
|
237
|
+
remaining = remaining_seconds(deadline)
|
|
238
|
+
if remaining is not None and remaining <= 0:
|
|
239
|
+
failures.append(_validator_failure(validator, "预算耗尽未执行", True))
|
|
185
240
|
truncated = True
|
|
186
|
-
break
|
|
187
|
-
try:
|
|
188
|
-
timeout = min(float(validator.get("timeout", 30000)) / 1000.0, remaining)
|
|
189
|
-
except (ValueError, TypeError):
|
|
190
|
-
timeout = remaining
|
|
191
|
-
command = str(validator.get("command", "")).replace(
|
|
192
|
-
"{files}", " ".join(matched)
|
|
193
|
-
)
|
|
194
|
-
if not command.strip():
|
|
195
241
|
continue
|
|
196
|
-
passed = False
|
|
197
|
-
detail = ""
|
|
198
242
|
try:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if not passed:
|
|
205
|
-
detail = (proc.stdout + proc.stderr).strip()[-400:]
|
|
206
|
-
except subprocess.TimeoutExpired:
|
|
207
|
-
detail = f"超时(>{timeout:.0f}s),跳过"
|
|
208
|
-
truncated = True
|
|
209
|
-
except Exception as exc:
|
|
210
|
-
# 命令在环境中不可用等:降级跳过,不打扰(E 场景)
|
|
211
|
-
cf_log.degrade(project_root, "stop_check", f"{validator.get('name')}:{exc}", sid)
|
|
243
|
+
argv = _validator_argv(project_root, validator, matched, strict)
|
|
244
|
+
except ValueError as exc:
|
|
245
|
+
failures.append(_validator_failure(validator, f"validator 配置错误: {exc}"))
|
|
246
|
+
continue
|
|
247
|
+
if not argv:
|
|
212
248
|
continue
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
"cmd": str(validator.get("command", ""))[:120], "passed": passed},
|
|
217
|
-
sid,
|
|
218
|
-
)
|
|
219
|
-
if not passed:
|
|
220
|
-
failures.append({
|
|
221
|
-
"name": validator.get("name", "validator"),
|
|
222
|
-
"on_fail": validator.get("on_fail", ""),
|
|
223
|
-
"detail": detail,
|
|
224
|
-
})
|
|
249
|
+
errors, incomplete = _validator_result(project_root, validator, argv, sid, deadline, strict)
|
|
250
|
+
failures.extend(errors)
|
|
251
|
+
truncated = truncated or incomplete
|
|
225
252
|
return failures, truncated
|
|
226
253
|
|
|
227
254
|
|
|
228
255
|
def _reason_text(failures: list, truncated: bool) -> str:
|
|
229
256
|
lines = ["收尾校验未通过(cf-stop):"]
|
|
230
257
|
for item in failures:
|
|
231
|
-
|
|
258
|
+
tag = "(未执行)" if item.get("incomplete") else ""
|
|
259
|
+
lines.append(f"✗ {item['name']}{tag}:{item['on_fail']}")
|
|
232
260
|
if item["detail"]:
|
|
233
261
|
lines.append(f" 输出片段: {item['detail'][:200]}")
|
|
234
262
|
if truncated:
|
|
@@ -237,7 +265,7 @@ def _reason_text(failures: list, truncated: bool) -> str:
|
|
|
237
265
|
return "\n".join(lines)
|
|
238
266
|
|
|
239
267
|
|
|
240
|
-
def
|
|
268
|
+
def _main() -> None:
|
|
241
269
|
try:
|
|
242
270
|
ensure_utf8_io()
|
|
243
271
|
raw = sys.stdin.read()
|
|
@@ -257,11 +285,16 @@ def main() -> None:
|
|
|
257
285
|
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
258
286
|
has_active = os.path.exists(marker)
|
|
259
287
|
files = []
|
|
288
|
+
# Single entry deadline constrains the whole chain (Done + validators),
|
|
289
|
+
# so long checks end as `incomplete` instead of being killed by the
|
|
290
|
+
# host without a verdict.
|
|
291
|
+
deadline = time.monotonic() + TOTAL_BUDGET_SECONDS
|
|
260
292
|
if has_active:
|
|
261
293
|
try:
|
|
262
294
|
active = load_active_task(project_root)
|
|
263
295
|
task_dir = os.path.join(project_root, active.task_dir)
|
|
264
|
-
|
|
296
|
+
gate_remaining = deadline - time.monotonic()
|
|
297
|
+
done = run_done_gate(project_root, task_dir, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
265
298
|
except (OSError, ValueError) as exc:
|
|
266
299
|
if enforcement == "required":
|
|
267
300
|
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
@@ -289,7 +322,7 @@ def main() -> None:
|
|
|
289
322
|
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
290
323
|
validators = load_validators(project_root)
|
|
291
324
|
failures, truncated = run_validators(
|
|
292
|
-
project_root, validators, files, sid
|
|
325
|
+
project_root, validators, files, sid, deadline=deadline
|
|
293
326
|
) if validators else ([], False)
|
|
294
327
|
failures = acceptance_failures + failures
|
|
295
328
|
if not failures:
|
|
@@ -305,8 +338,16 @@ def main() -> None:
|
|
|
305
338
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
306
339
|
except Exception as exc:
|
|
307
340
|
_log(f"cf_stop_hook error: {exc}")
|
|
341
|
+
if locals().get("enforcement") == "required":
|
|
342
|
+
sys.stdout.write(json.dumps({"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: {exc}"}, ensure_ascii=False))
|
|
308
343
|
return
|
|
309
344
|
|
|
310
345
|
|
|
346
|
+
def main() -> None:
|
|
347
|
+
with execution_session():
|
|
348
|
+
_main()
|
|
349
|
+
|
|
350
|
+
|
|
311
351
|
if __name__ == "__main__":
|
|
312
352
|
main()
|
|
353
|
+
timing_log("cf_stop_hook")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Programmatic task-file index: parse TASK statuses/depends, compute DAG batches.
|
|
3
|
+
|
|
4
|
+
Single worktrees allow one active TASK at a time, so "parallel" below means
|
|
5
|
+
*independently developable* (no dependency path between them) — not
|
|
6
|
+
simultaneously activatable, a distinction `cf-task:graph` used to blur.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from cf_task_state import FINISHED_STATUSES as _DONE_STATUSES
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TaskNode:
|
|
25
|
+
task_id: str
|
|
26
|
+
status: str
|
|
27
|
+
depends: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_task_file(task_file: str) -> tuple[TaskNode, ...]:
|
|
31
|
+
"""Parse every `## TASK-xxx` section into status/depends nodes.
|
|
32
|
+
|
|
33
|
+
Unknown statuses are preserved verbatim (e.g. `verified` from the E2E
|
|
34
|
+
flow) — the parser never rejects them, completion checks treat done and
|
|
35
|
+
verified as finished.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
text = Path(task_file).read_text(encoding="utf-8")
|
|
39
|
+
except (OSError, UnicodeError) as exc:
|
|
40
|
+
raise ValueError(f"task_read_error: {exc}") from exc
|
|
41
|
+
nodes: list[TaskNode] = []
|
|
42
|
+
matches = list(re.finditer(r"(?m)^## (TASK-\d+):[^\n]*$", text))
|
|
43
|
+
for index, match in enumerate(matches):
|
|
44
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
45
|
+
section = text[match.start():end]
|
|
46
|
+
status_match = re.search(r"(?m)^- \*\*Status\*\*:[ \t]*([^\n]+)", section)
|
|
47
|
+
status = status_match.group(1).strip() if status_match else "draft"
|
|
48
|
+
depends_match = re.search(r"(?m)^- \*\*Depends\*\*:[ \t]*([^\n]*)", section)
|
|
49
|
+
depends: tuple[str, ...] = ()
|
|
50
|
+
if depends_match:
|
|
51
|
+
depends = tuple(
|
|
52
|
+
part.strip()
|
|
53
|
+
for part in re.split(r"[,,\s]+", depends_match.group(1).strip())
|
|
54
|
+
if re.fullmatch(r"TASK-\d+", part.strip())
|
|
55
|
+
)
|
|
56
|
+
nodes.append(TaskNode(match.group(1), status, depends))
|
|
57
|
+
if not nodes:
|
|
58
|
+
raise ValueError("no TASK sections found")
|
|
59
|
+
return tuple(nodes)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def task_batches(nodes: Sequence[TaskNode]) -> list[list[str]]:
|
|
63
|
+
"""Topological batches over unfinished TASKs; unknown deps and cycles raise."""
|
|
64
|
+
ids = {node.task_id for node in nodes}
|
|
65
|
+
for node in nodes:
|
|
66
|
+
unknown = [dep for dep in node.depends if dep not in ids]
|
|
67
|
+
if unknown:
|
|
68
|
+
raise ValueError(f"unknown depends: {node.task_id} -> {', '.join(unknown)}")
|
|
69
|
+
pending = {node.task_id for node in nodes if node.status not in _DONE_STATUSES}
|
|
70
|
+
finished = {node.task_id for node in nodes if node.status in _DONE_STATUSES}
|
|
71
|
+
deps = {node.task_id: set(node.depends) for node in nodes}
|
|
72
|
+
batches: list[list[str]] = []
|
|
73
|
+
while pending:
|
|
74
|
+
ready = sorted(task for task in pending if deps[task] <= finished)
|
|
75
|
+
if not ready:
|
|
76
|
+
raise ValueError(f"dependency cycle among: {', '.join(sorted(pending))}")
|
|
77
|
+
batches.append(ready)
|
|
78
|
+
finished.update(ready)
|
|
79
|
+
pending.difference_update(ready)
|
|
80
|
+
return batches
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def dependency_components(nodes: Sequence[TaskNode]) -> list[list[str]]:
|
|
84
|
+
"""Connected dependency components; different components are independent."""
|
|
85
|
+
parent = {node.task_id: node.task_id for node in nodes}
|
|
86
|
+
|
|
87
|
+
def find(task: str) -> str:
|
|
88
|
+
while parent[task] != task:
|
|
89
|
+
parent[task] = parent[parent[task]]
|
|
90
|
+
task = parent[task]
|
|
91
|
+
return task
|
|
92
|
+
|
|
93
|
+
for node in nodes:
|
|
94
|
+
for dep in node.depends:
|
|
95
|
+
left, right = find(node.task_id), find(dep)
|
|
96
|
+
if left != right:
|
|
97
|
+
parent[left] = right
|
|
98
|
+
groups: dict[str, list[str]] = {}
|
|
99
|
+
for node in nodes:
|
|
100
|
+
groups.setdefault(find(node.task_id), []).append(node.task_id)
|
|
101
|
+
return [sorted(members) for members in groups.values()]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def independent_groups(nodes: Sequence[TaskNode]) -> list[list[str]]:
|
|
105
|
+
"""Compatibility alias: groups are independent of each other, not within."""
|
|
106
|
+
return dependency_components(nodes)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def index_data(task_file: str) -> dict[str, object]:
|
|
110
|
+
nodes = parse_task_file(task_file)
|
|
111
|
+
return {
|
|
112
|
+
"tasks": [
|
|
113
|
+
{"id": node.task_id, "status": node.status, "depends": list(node.depends)}
|
|
114
|
+
for node in nodes
|
|
115
|
+
],
|
|
116
|
+
"batches": task_batches(nodes),
|
|
117
|
+
"independent_groups": independent_groups(nodes),
|
|
118
|
+
"dependency_components": dependency_components(nodes),
|
|
119
|
+
"note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
124
|
+
parser = argparse.ArgumentParser(prog="cf_task_index.py")
|
|
125
|
+
parser.add_argument("--task-file", required=True)
|
|
126
|
+
parser.add_argument("--dag", action="store_true", help="输出拓扑批次与独立分组")
|
|
127
|
+
parser.add_argument("--json", action="store_true")
|
|
128
|
+
args = parser.parse_args(argv)
|
|
129
|
+
try:
|
|
130
|
+
data = index_data(args.task_file)
|
|
131
|
+
except (OSError, ValueError) as exc:
|
|
132
|
+
stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
133
|
+
return 2
|
|
134
|
+
if args.json or args.dag:
|
|
135
|
+
stdout.write(json.dumps(data, ensure_ascii=False))
|
|
136
|
+
else:
|
|
137
|
+
lines = ["TASK 索引:"]
|
|
138
|
+
for item in data["tasks"]:
|
|
139
|
+
lines.append(f" - {item['id']}: {item['status']} (依赖: {', '.join(item['depends']) or '无'})")
|
|
140
|
+
for index, batch in enumerate(data["batches"], 1):
|
|
141
|
+
lines.append(f"批次 {index}(可独立开发): {', '.join(batch)}")
|
|
142
|
+
stdout.write("\n".join(lines) + "\n")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
raise SystemExit(main())
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
from dataclasses import dataclass, replace
|
|
7
7
|
import hashlib
|
|
8
|
+
import json
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
import time
|
|
10
11
|
from typing import Mapping, Optional
|
|
@@ -28,6 +29,9 @@ from cf_spec_metadata import load_spec_metadata
|
|
|
28
29
|
from cf_spec_resolver import resolve_candidates, SpecCandidate
|
|
29
30
|
from cf_spec_session import context_sha256
|
|
30
31
|
from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
|
|
32
|
+
from cf_core import phase_timing
|
|
33
|
+
from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
|
|
34
|
+
from cf_exec_base import execution_session
|
|
31
35
|
|
|
32
36
|
|
|
33
37
|
@dataclass(frozen=True)
|
|
@@ -50,6 +54,45 @@ def _context_path(task_dir: str) -> str:
|
|
|
50
54
|
return str(Path(task_dir) / "spec-context.yml")
|
|
51
55
|
|
|
52
56
|
|
|
57
|
+
def _manual_manifest_issue(task_dir: str, owner: str = "") -> str:
|
|
58
|
+
path = Path(task_dir) / ".acceptance-manifest.json"
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return ""
|
|
61
|
+
try:
|
|
62
|
+
import json
|
|
63
|
+
|
|
64
|
+
data = load_manifest(path)
|
|
65
|
+
pending = [
|
|
66
|
+
item.get("id", "unknown")
|
|
67
|
+
for item in data.get("scenarios", [])
|
|
68
|
+
if item.get("kind") == "manual" and not verified_evidence(item)
|
|
69
|
+
and (not owner or not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") == owner)
|
|
70
|
+
]
|
|
71
|
+
return f"manual 场景未完成: {', '.join(pending)}" if pending else ""
|
|
72
|
+
except (OSError, ValueError, TypeError):
|
|
73
|
+
return "acceptance manifest invalid"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _acceptance_baseline_issue(task_dir: str, owner: str) -> str:
|
|
77
|
+
from cf_workflow_service import locate_task_file
|
|
78
|
+
path = Path(task_dir) / ".acceptance-manifest.json"
|
|
79
|
+
task = locate_task_file(task_dir, owner)
|
|
80
|
+
try:
|
|
81
|
+
requires = task is not None and "## Acceptance Coverage" in task.read_text(encoding="utf-8")
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return "acceptance_manifest_missing" if requires else ""
|
|
84
|
+
data = load_manifest(path)
|
|
85
|
+
if requires:
|
|
86
|
+
from cf_acceptance_manifest import validate_manifest
|
|
87
|
+
valid, reason = validate_manifest(str(task), str(path))
|
|
88
|
+
if not valid:
|
|
89
|
+
return reason
|
|
90
|
+
validate_execution_baseline(path, data)
|
|
91
|
+
return ""
|
|
92
|
+
except (OSError, ValueError) as exc:
|
|
93
|
+
return f"acceptance_manifest_invalid: {exc}"
|
|
94
|
+
|
|
95
|
+
|
|
53
96
|
def _required_candidate(candidate: SpecCandidate) -> bool:
|
|
54
97
|
metadata = candidate.metadata
|
|
55
98
|
return metadata.enforcement == "required" and any(
|
|
@@ -157,15 +200,45 @@ def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object
|
|
|
157
200
|
}
|
|
158
201
|
|
|
159
202
|
|
|
160
|
-
def
|
|
203
|
+
def _run_acceptance(root: str, task_dir: str, owner: str, include_e2e: bool,
|
|
204
|
+
deadline: Optional[float]) -> str:
|
|
205
|
+
baseline_issue = _acceptance_baseline_issue(task_dir, owner)
|
|
206
|
+
if baseline_issue:
|
|
207
|
+
return baseline_issue
|
|
208
|
+
manual_issue = _manual_manifest_issue(task_dir, owner)
|
|
209
|
+
if manual_issue:
|
|
210
|
+
return manual_issue
|
|
211
|
+
manifest_path = Path(task_dir) / ".acceptance-manifest.json"
|
|
212
|
+
if manifest_path.is_file():
|
|
213
|
+
from cf_acceptance_runner import run_manifest
|
|
214
|
+
scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e, owner=owner, deadline=deadline)
|
|
215
|
+
if scenario_result["decision"] == "block":
|
|
216
|
+
reason = scenario_result.get("error") or "acceptance scenario failed"
|
|
217
|
+
return f"acceptance scenario failed: {reason}"
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
|
|
222
|
+
active = load_active_task(root)
|
|
223
|
+
owner = task_id or active.task_id
|
|
224
|
+
if owner != active.task_id or (Path(root) / active.task_dir).resolve() != Path(task_dir).resolve():
|
|
225
|
+
return DoneResult("block", (), (), "active_mismatch: task identity does not match marker")
|
|
226
|
+
started = time.monotonic()
|
|
161
227
|
scope_result = evaluate_scope(root, task_dir)
|
|
228
|
+
phase_timing("done.evaluate_scope", started)
|
|
162
229
|
if scope_result.decision == "pause":
|
|
163
230
|
return DoneResult("block", scope_result.files, (), scope_result.message)
|
|
231
|
+
deadline = started + budget if budget is not None else None
|
|
232
|
+
issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline)
|
|
233
|
+
if issue:
|
|
234
|
+
return DoneResult("block", scope_result.files, (), issue)
|
|
235
|
+
phase_started = time.monotonic()
|
|
164
236
|
context = load_context(_context_path(task_dir))
|
|
165
237
|
diff_hash = _diff_hash(root, scope_result.files)
|
|
238
|
+
phase_timing("done.load_context_and_diff", phase_started)
|
|
166
239
|
all_evidence: list[Mapping[str, object]] = []
|
|
167
|
-
started = time.monotonic()
|
|
168
240
|
for binding in context.bindings:
|
|
241
|
+
phase_started = time.monotonic()
|
|
169
242
|
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
|
|
170
243
|
confirmations: dict[str, Mapping[str, object]] = {}
|
|
171
244
|
for rule in binding.rules:
|
|
@@ -176,9 +249,17 @@ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optiona
|
|
|
176
249
|
result = run_all_verifiers(
|
|
177
250
|
metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining
|
|
178
251
|
)
|
|
252
|
+
phase_timing(f"done.verify.{binding.spec_id}", phase_started)
|
|
179
253
|
all_evidence.extend(_evidence_data(item) for item in result.evidence)
|
|
180
254
|
updated = _apply_evidence(context, tuple(all_evidence))
|
|
181
255
|
if updated != context:
|
|
182
256
|
save_context(_context_path(task_dir), updated)
|
|
183
257
|
gate = validate_stage(updated, "code", diff_sha256=diff_hash)
|
|
184
|
-
|
|
258
|
+
phase_timing("done.total", started)
|
|
259
|
+
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
|
|
260
|
+
"; ".join(issue.message for issue in gate.errors))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
|
|
264
|
+
with execution_session():
|
|
265
|
+
return _run_done_gate(root, task_dir, cheap, budget, include_e2e, task_id)
|