@michaelxwb/code-flow 0.6.2 → 0.6.4
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/README.md +2 -2
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -0
- package/src/adapters/claude/commands/cf-spec.md +7 -3
- 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 +2 -2
- package/src/adapters/claude/commands/cf-task/start.md +96 -25
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/claude/commands/cf-validate.md +15 -13
- package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
- 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-plan/SKILL.md +2 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
- package/src/adapters/costrict/commands/cf-init.md +1 -0
- package/src/adapters/costrict/commands/cf-spec.md +7 -3
- 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 +2 -2
- package/src/adapters/costrict/commands/cf-task/start.md +96 -25
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/costrict/commands/cf-validate.md +15 -13
- package/src/adapters/opencode/commands/cf-init.md +9 -4
- package/src/adapters/opencode/commands/cf-spec.md +6 -2
- 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 +2 -2
- package/src/adapters/opencode/commands/cf-task/start.md +96 -25
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/opencode/commands/cf-validate.md +15 -13
- package/src/adapters/opencode/opencode.json +1 -2
- package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
- package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
- package/src/cli.js +228 -64
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +34 -1
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
- package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
- package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
- package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
- package/src/core/code-flow/scripts/cf_task_index.py +148 -0
- package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
- 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 +9 -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
- package/src/core/code-flow/validation.yml +5 -0
|
@@ -74,11 +74,28 @@ def _evidence_issue(rule: RuleBinding, status: RuleStageStatus, diff_sha256: Opt
|
|
|
74
74
|
return "stale_evidence"
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def _cheap_skip(status: RuleStageStatus) -> bool:
|
|
78
|
+
"""Latest evidence is a cheap-gate skip (command/test verifier deferred)."""
|
|
79
|
+
if not status.evidence:
|
|
80
|
+
return False
|
|
81
|
+
latest = status.evidence[-1]
|
|
82
|
+
return isinstance(latest, Mapping) and latest.get("error_code") == "skipped_in_cheap_gate"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _status_issue(
|
|
86
|
+
rule: RuleBinding,
|
|
87
|
+
status: RuleStageStatus,
|
|
88
|
+
now: datetime,
|
|
89
|
+
diff_sha256: Optional[str],
|
|
90
|
+
allow_cheap_skips: bool = False,
|
|
91
|
+
) -> Optional[str]:
|
|
78
92
|
if status.status == "applied":
|
|
79
93
|
return None
|
|
80
94
|
if status.status == "verified":
|
|
81
95
|
return _evidence_issue(rule, status, diff_sha256)
|
|
96
|
+
if allow_cheap_skips and status.status == "unverified" and _cheap_skip(status):
|
|
97
|
+
# 轻量门禁(stop 阶段):命令类 verifier 未执行属预期,留给 finish 全量门禁。
|
|
98
|
+
return None
|
|
82
99
|
if status.status == "not_applicable":
|
|
83
100
|
return None if _decision_valid(status.decision) else "decision_invalid"
|
|
84
101
|
if status.status == "waived":
|
|
@@ -87,7 +104,11 @@ def _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, dif
|
|
|
87
104
|
|
|
88
105
|
|
|
89
106
|
def _binding_issues(
|
|
90
|
-
binding: SpecBinding,
|
|
107
|
+
binding: SpecBinding,
|
|
108
|
+
stage: str,
|
|
109
|
+
now: datetime,
|
|
110
|
+
diff_sha256: Optional[str],
|
|
111
|
+
allow_cheap_skips: bool = False,
|
|
91
112
|
) -> tuple[list[GateIssue], list[GateIssue]]:
|
|
92
113
|
errors: list[GateIssue] = []
|
|
93
114
|
warnings: list[GateIssue] = []
|
|
@@ -97,7 +118,7 @@ def _binding_issues(
|
|
|
97
118
|
continue
|
|
98
119
|
if rule.enforcement == "advisory" and status.status == "pending":
|
|
99
120
|
continue
|
|
100
|
-
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
|
|
121
|
+
code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256, allow_cheap_skips)
|
|
101
122
|
if code is None:
|
|
102
123
|
continue
|
|
103
124
|
issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
|
|
@@ -113,13 +134,14 @@ def validate_stage(
|
|
|
113
134
|
task_id: str = "",
|
|
114
135
|
now: Optional[datetime] = None,
|
|
115
136
|
diff_sha256: Optional[str] = None,
|
|
137
|
+
allow_cheap_skips: bool = False,
|
|
116
138
|
) -> GateResult:
|
|
117
139
|
del artifact, task_id
|
|
118
140
|
current = now or datetime.now(timezone.utc)
|
|
119
141
|
errors: list[GateIssue] = []
|
|
120
142
|
warnings: list[GateIssue] = []
|
|
121
143
|
for binding in context.bindings:
|
|
122
|
-
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
|
|
144
|
+
binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256, allow_cheap_skips)
|
|
123
145
|
errors.extend(binding_errors)
|
|
124
146
|
warnings.extend(binding_warnings)
|
|
125
147
|
refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
|
|
@@ -18,7 +18,7 @@ from cf_spec_metadata import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
|
|
21
|
+
SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
|
|
22
22
|
_config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
|
|
23
23
|
_metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
|
|
24
24
|
|
|
@@ -174,7 +174,12 @@ def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str
|
|
|
174
174
|
if relative.startswith("_session/"):
|
|
175
175
|
return "task", ()
|
|
176
176
|
matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
|
|
177
|
-
|
|
177
|
+
if matched:
|
|
178
|
+
return "path", matched
|
|
179
|
+
# A domain without patterns is truly global. A domain WITH patterns that
|
|
180
|
+
# match nothing is irrelevant to these paths ("unmatched") — discovery
|
|
181
|
+
# keeps it, but injection and scope expansion must not consume it.
|
|
182
|
+
return ("global", ()) if not patterns else ("unmatched", ())
|
|
178
183
|
|
|
179
184
|
|
|
180
185
|
def _candidate_for(
|
|
@@ -137,7 +137,7 @@ def _active_route(root: str) -> RouteResult:
|
|
|
137
137
|
if current_hash != active.context_sha256:
|
|
138
138
|
raise RouterError(
|
|
139
139
|
"active_context_drift",
|
|
140
|
-
"active marker Context hash does not match; run `
|
|
140
|
+
"active marker Context hash does not match; run `cf_spec_context.py refresh --task-dir <dir> --root <root> --json` to re-sync it (refresh updates the marker hash), or `active doctor --resync` with the hash from `status --json`",
|
|
141
141
|
)
|
|
142
142
|
cached = _cached_projection(root, task_dir, active.task_id, current_hash)
|
|
143
143
|
if cached is None:
|
|
@@ -162,6 +162,8 @@ def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) ->
|
|
|
162
162
|
raise RouterError("invalid_spec_metadata", str(exc)) from exc
|
|
163
163
|
specs = []
|
|
164
164
|
for candidate in candidates:
|
|
165
|
+
if candidate.scope == "unmatched":
|
|
166
|
+
continue
|
|
165
167
|
target = Path(root) / ".code-flow/specs" / candidate.path
|
|
166
168
|
try:
|
|
167
169
|
raw = target.read_text(encoding="utf-8")
|
|
@@ -36,7 +36,8 @@ def _task_section(text: str, task_id: str) -> str:
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _refs(section: str) -> tuple[str, ...]:
|
|
39
|
-
|
|
39
|
+
# [ \t]* 而非 \s*:空 Spec-Refs 不得跨行吞掉下一字段(Acceptance-Refs 等)
|
|
40
|
+
match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:[ \t]*(.*)$", section)
|
|
40
41
|
if match is None:
|
|
41
42
|
raise ValueError("spec_refs_missing")
|
|
42
43
|
return tuple(item.strip() for item in match.group(1).split(",") if item.strip())
|
|
@@ -83,7 +84,7 @@ def project_task_session(
|
|
|
83
84
|
if len(header + "".join(lines) + candidate + footer) > max_chars:
|
|
84
85
|
break
|
|
85
86
|
lines.append(candidate)
|
|
86
|
-
truncated = len(lines) < len(refs)
|
|
87
|
+
truncated = len(lines) < len(refs) or len(header + "".join(lines) + footer) > max_chars
|
|
87
88
|
if truncated:
|
|
88
89
|
footer = "\n> 投影超过预算;完整 Context 保留在需求目录,请拆分 TASK。\n" + footer
|
|
89
90
|
text = (header + "".join(lines) + footer)[:max_chars]
|
|
@@ -18,6 +18,7 @@ from typing import Mapping, Optional, Sequence
|
|
|
18
18
|
|
|
19
19
|
from cf_checks import run_regex_verifier
|
|
20
20
|
from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
|
|
21
|
+
from cf_exec_base import execution_session, run_command
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
_RESULT_CACHE: dict[str, dict[str, object]] = {}
|
|
@@ -66,10 +67,23 @@ def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, s
|
|
|
66
67
|
"files": scope.files,
|
|
67
68
|
"diff": scope.diff_sha256,
|
|
68
69
|
"confirmation": confirmation or {},
|
|
70
|
+
"artifact_content": _artifact_fingerprint(verifier, scope),
|
|
69
71
|
}
|
|
70
72
|
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
71
73
|
|
|
72
74
|
|
|
75
|
+
def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
|
|
76
|
+
if verifier.type != "document":
|
|
77
|
+
return None
|
|
78
|
+
relative = verifier.config.get("artifact")
|
|
79
|
+
if not isinstance(relative, str):
|
|
80
|
+
return "invalid"
|
|
81
|
+
try:
|
|
82
|
+
return hashlib.sha256((Path(scope.root) / relative).read_bytes()).hexdigest()
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
return f"unreadable:{exc}"
|
|
85
|
+
|
|
86
|
+
|
|
73
87
|
def _load_result_cache(root: str) -> None:
|
|
74
88
|
global _RESULT_CACHE_ROOT, _RESULT_CACHE, _RESULT_CACHE_DIRTY
|
|
75
89
|
if _RESULT_CACHE_ROOT == root:
|
|
@@ -266,30 +280,22 @@ def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap
|
|
|
266
280
|
cwd = _config_string(config, "cwd") or "."
|
|
267
281
|
timeout_value = config.get("timeout", 30)
|
|
268
282
|
timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
|
|
269
|
-
if timeout_cap is not None:
|
|
270
|
-
timeout = min(timeout, timeout_cap)
|
|
271
283
|
allowed_value = config.get("allowed_exit_codes", [0])
|
|
272
284
|
allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
cwd=str(Path(scope.root) / cwd),
|
|
277
|
-
capture_output=True,
|
|
278
|
-
text=True,
|
|
279
|
-
timeout=timeout,
|
|
280
|
-
check=False,
|
|
281
|
-
)
|
|
282
|
-
except subprocess.TimeoutExpired:
|
|
285
|
+
deadline = None if timeout_cap is None else time.monotonic() + timeout_cap
|
|
286
|
+
completed = run_command(argv, str(Path(scope.root) / cwd), timeout, deadline)
|
|
287
|
+
if completed["status"] in ("timeout", "deadline_exceeded"):
|
|
283
288
|
return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
|
|
284
|
-
|
|
285
|
-
return _Outcome(False, "command_unavailable", None, {"argv": argv, "error":
|
|
289
|
+
if completed["status"] == "spawn_error":
|
|
290
|
+
return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": completed["stderr"]})
|
|
286
291
|
details = {
|
|
287
292
|
"argv": argv,
|
|
288
|
-
"exit_code": completed
|
|
289
|
-
"stdout_sha256": _hash_bytes(completed
|
|
290
|
-
"stderr_sha256": _hash_bytes(completed
|
|
293
|
+
"exit_code": completed["returncode"],
|
|
294
|
+
"stdout_sha256": _hash_bytes(str(completed["stdout"]).encode()),
|
|
295
|
+
"stderr_sha256": _hash_bytes(str(completed["stderr"]).encode()),
|
|
296
|
+
"output": (str(completed["stdout"]) + str(completed["stderr"]))[-4000:],
|
|
291
297
|
}
|
|
292
|
-
passed = completed
|
|
298
|
+
passed = completed["returncode"] in allowed
|
|
293
299
|
return _Outcome(passed, None if passed else "command_failed", None, details)
|
|
294
300
|
|
|
295
301
|
|
|
@@ -379,7 +385,16 @@ def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: Verification
|
|
|
379
385
|
)
|
|
380
386
|
|
|
381
387
|
|
|
382
|
-
def
|
|
388
|
+
def _missing_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
|
|
389
|
+
details = {"rule": rule.ref}
|
|
390
|
+
return VerificationEvidence(
|
|
391
|
+
f"{metadata.id}#{rule.ref}", datetime.now(timezone.utc).isoformat(),
|
|
392
|
+
"unverified", rule.text_sha256, None, scope.diff_sha256,
|
|
393
|
+
_result_hash("unverified", "verifier_missing", details), "verifier_missing", details,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _run_all_verifiers(
|
|
383
398
|
metadata: SpecMetadata,
|
|
384
399
|
scope: VerificationScope,
|
|
385
400
|
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
@@ -402,18 +417,7 @@ def run_all_verifiers(
|
|
|
402
417
|
continue
|
|
403
418
|
verifier = verifier_by_rule.get(rule.ref)
|
|
404
419
|
if verifier is None:
|
|
405
|
-
|
|
406
|
-
evidence_by_ref[rule.ref] = VerificationEvidence(
|
|
407
|
-
f"{metadata.id}#{rule.ref}",
|
|
408
|
-
datetime.now(timezone.utc).isoformat(),
|
|
409
|
-
"unverified",
|
|
410
|
-
rule.text_sha256,
|
|
411
|
-
None,
|
|
412
|
-
scope.diff_sha256,
|
|
413
|
-
_result_hash("unverified", "verifier_missing", details),
|
|
414
|
-
"verifier_missing",
|
|
415
|
-
details,
|
|
416
|
-
)
|
|
420
|
+
evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
|
|
417
421
|
continue
|
|
418
422
|
if skip_command and verifier.type in ("command", "test"):
|
|
419
423
|
evidence_by_ref[rule.ref] = _skipped_evidence(metadata, rule, scope)
|
|
@@ -436,6 +440,13 @@ def run_all_verifiers(
|
|
|
436
440
|
return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
|
|
437
441
|
|
|
438
442
|
|
|
443
|
+
def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
|
|
444
|
+
confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
|
|
445
|
+
skip_command: bool = False, timeout_budget: Optional[float] = None) -> VerificationResult:
|
|
446
|
+
with execution_session():
|
|
447
|
+
return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
|
|
448
|
+
|
|
449
|
+
|
|
439
450
|
def evidence_is_fresh(
|
|
440
451
|
evidence: VerificationEvidence,
|
|
441
452
|
rule_text_sha256: str,
|
|
@@ -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,
|
|
@@ -93,6 +96,34 @@ def session_edited_files(project_root: str, sid: str) -> list:
|
|
|
93
96
|
return files
|
|
94
97
|
|
|
95
98
|
|
|
99
|
+
def _root_scoped_files(project_root: str, files: list) -> list:
|
|
100
|
+
"""Drop worktree-internal / outside-root paths.
|
|
101
|
+
|
|
102
|
+
Parallel subagents edit another working copy (.code-flow/worktrees/<run>/<TASK>/);
|
|
103
|
+
those paths must not trigger this root's validators.
|
|
104
|
+
"""
|
|
105
|
+
root = os.path.realpath(project_root)
|
|
106
|
+
scoped = []
|
|
107
|
+
for rel in files:
|
|
108
|
+
path = normalize_path(rel)
|
|
109
|
+
if path.startswith(".code-flow/worktrees/"):
|
|
110
|
+
continue
|
|
111
|
+
absolute = os.path.realpath(os.path.join(root, path))
|
|
112
|
+
if absolute == root or absolute.startswith(root + os.sep):
|
|
113
|
+
scoped.append(rel)
|
|
114
|
+
return scoped
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stop_validators(project_root: str) -> list:
|
|
118
|
+
"""Per-stop validators: heavy entries (full tests / e2e suites) are skipped
|
|
119
|
+
on every Stop and only run via the explicit `cf-validate` command (or CI)."""
|
|
120
|
+
return [
|
|
121
|
+
validator
|
|
122
|
+
for validator in load_validators(project_root)
|
|
123
|
+
if not (isinstance(validator, dict) and validator.get("heavy") is True)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
96
127
|
def _is_active_task_file(rel_path: str) -> bool:
|
|
97
128
|
path = normalize_path(rel_path)
|
|
98
129
|
if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
|
|
@@ -110,7 +141,9 @@ def _subsection(section: str, heading: str) -> str:
|
|
|
110
141
|
|
|
111
142
|
def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
|
|
112
143
|
status = re.search(r"(?m)^- \*\*Status\*\*: ([^\n]+)", section)
|
|
113
|
-
|
|
144
|
+
# done = implementation finished; verified = E2E/final acceptance closed.
|
|
145
|
+
# Both enter the same contract checks; anything else is still in flight.
|
|
146
|
+
if not status or status.group(1).strip() not in ("done", "verified"):
|
|
114
147
|
return ""
|
|
115
148
|
refs_match = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*: ([^\n]+)", section)
|
|
116
149
|
if not refs_match:
|
|
@@ -125,17 +158,17 @@ def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
|
|
|
125
158
|
evidence = _subsection(section, "Acceptance Evidence")
|
|
126
159
|
if not contract or not evidence:
|
|
127
160
|
return f"{task_id} 缺少 Acceptance Contract 或 Acceptance Evidence"
|
|
128
|
-
if _UNVERIFIED_RE.search(contract) or _UNVERIFIED_RE.search(evidence):
|
|
129
|
-
return f"{task_id} 验收契约仍有 planned/pending/TBD"
|
|
130
161
|
for scenario in scenarios:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
coverage_rows = [line for line in coverage.splitlines() if scenario_line(line, scenario)]
|
|
163
|
+
cells = coverage_rows[0].strip().strip("|").split("|") if coverage_rows else []
|
|
164
|
+
deferred = status.group(1).strip() == "done" and len(cells) >= 3 and cells[2].strip().lower() == "e2e"
|
|
165
|
+
allowed = {"verified", "e2e_deferred"} if deferred else {"verified"}
|
|
166
|
+
states = []
|
|
167
|
+
for body in (contract, evidence, coverage):
|
|
168
|
+
found = [line_status(line, scenario) for line in body.splitlines() if scenario_line(line, scenario)]
|
|
169
|
+
states.append(found[-1] if found else "")
|
|
170
|
+
if not all(state in allowed for state in states):
|
|
171
|
+
return f"{task_id} 的 {scenario} 状态未闭环(planned/pending/TBD/failed 或缺少 verified;仅实现阶段 E2E 可 e2e_deferred)"
|
|
139
172
|
return ""
|
|
140
173
|
|
|
141
174
|
|
|
@@ -166,70 +199,92 @@ def task_acceptance_failures(project_root: str, files: list) -> list:
|
|
|
166
199
|
return failures
|
|
167
200
|
|
|
168
201
|
|
|
202
|
+
def _validator_failure(validator: dict, detail: str, incomplete: bool = False) -> dict:
|
|
203
|
+
result = {"name": validator.get("name", "validator"),
|
|
204
|
+
"on_fail": validator.get("on_fail", ""), "detail": detail}
|
|
205
|
+
if incomplete:
|
|
206
|
+
result["incomplete"] = True
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validator_argv(root: str, validator: dict, matched: list, strict: bool) -> list:
|
|
211
|
+
existing = [name for name in matched if os.path.isfile(os.path.join(root, name))]
|
|
212
|
+
template = str(validator.get("command", ""))
|
|
213
|
+
if "{files}" in template and not existing:
|
|
214
|
+
return []
|
|
215
|
+
argv = build_argv(template, existing)
|
|
216
|
+
if not argv and strict:
|
|
217
|
+
raise ValueError("validator command missing")
|
|
218
|
+
return argv
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _validator_result(root: str, validator: dict, argv: list, sid: str,
|
|
222
|
+
deadline: float, strict: bool) -> tuple[list, bool]:
|
|
223
|
+
try:
|
|
224
|
+
timeout = float(validator.get("timeout", 30000)) / 1000.0
|
|
225
|
+
except (ValueError, TypeError):
|
|
226
|
+
if strict:
|
|
227
|
+
return [_validator_failure(validator, "invalid validator timeout")], False
|
|
228
|
+
timeout = 30.0
|
|
229
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
230
|
+
return [_validator_failure(validator, "invalid validator timeout")], False
|
|
231
|
+
outcome = run_command(argv, root, timeout, deadline)
|
|
232
|
+
passed = outcome["status"] == "ok" and outcome["returncode"] == 0
|
|
233
|
+
if outcome["status"] == "deadline_exceeded":
|
|
234
|
+
return [_validator_failure(validator, "预算耗尽未执行", True)], True
|
|
235
|
+
timed_out = outcome["status"] == "timeout"
|
|
236
|
+
detail = (f"超时(>{timeout:.0f}s),未完成" if timed_out else
|
|
237
|
+
(str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-400:])
|
|
238
|
+
if outcome["status"] == "spawn_error":
|
|
239
|
+
cf_log.degrade(root, "stop_check", f"{validator.get('name')}:{outcome['stderr']}", sid)
|
|
240
|
+
if not strict:
|
|
241
|
+
return [], False
|
|
242
|
+
cf_log.append_event(root, "stop_check",
|
|
243
|
+
{"trigger": validator.get("trigger", ""),
|
|
244
|
+
"cmd": str(validator.get("command", ""))[:120], "passed": passed}, sid)
|
|
245
|
+
return ([] if passed else [_validator_failure(validator, detail)]), timed_out
|
|
246
|
+
|
|
247
|
+
|
|
169
248
|
def run_validators(
|
|
170
249
|
project_root: str, validators: list, files: list, sid: str,
|
|
171
250
|
total_budget: float = TOTAL_BUDGET_SECONDS,
|
|
251
|
+
deadline: float = 0.0,
|
|
252
|
+
strict: bool = False,
|
|
172
253
|
) -> tuple:
|
|
173
|
-
"""
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
truncated = False
|
|
177
|
-
deadline = time.monotonic() + total_budget
|
|
254
|
+
"""Match validators, execute argv under one deadline, report unrun work."""
|
|
255
|
+
failures, truncated = [], False
|
|
256
|
+
deadline = deadline or time.monotonic() + total_budget
|
|
178
257
|
for validator in validators:
|
|
179
258
|
if not isinstance(validator, dict):
|
|
259
|
+
if strict:
|
|
260
|
+
failures.append(_validator_failure({}, "invalid validator record"))
|
|
180
261
|
continue
|
|
181
262
|
matched = [f for f in files if trigger_matches(validator.get("trigger", ""), f)]
|
|
182
263
|
if not matched:
|
|
183
264
|
continue
|
|
184
|
-
remaining = deadline
|
|
185
|
-
if remaining <= 0:
|
|
265
|
+
remaining = remaining_seconds(deadline)
|
|
266
|
+
if remaining is not None and remaining <= 0:
|
|
267
|
+
failures.append(_validator_failure(validator, "预算耗尽未执行", True))
|
|
186
268
|
truncated = True
|
|
187
|
-
break
|
|
188
|
-
try:
|
|
189
|
-
timeout = min(float(validator.get("timeout", 30000)) / 1000.0, remaining)
|
|
190
|
-
except (ValueError, TypeError):
|
|
191
|
-
timeout = remaining
|
|
192
|
-
command = str(validator.get("command", "")).replace(
|
|
193
|
-
"{files}", " ".join(matched)
|
|
194
|
-
)
|
|
195
|
-
if not command.strip():
|
|
196
269
|
continue
|
|
197
|
-
passed = False
|
|
198
|
-
detail = ""
|
|
199
270
|
try:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
passed = proc.returncode == 0
|
|
205
|
-
if not passed:
|
|
206
|
-
detail = (proc.stdout + proc.stderr).strip()[-400:]
|
|
207
|
-
except subprocess.TimeoutExpired:
|
|
208
|
-
detail = f"超时(>{timeout:.0f}s),跳过"
|
|
209
|
-
truncated = True
|
|
210
|
-
except Exception as exc:
|
|
211
|
-
# 命令在环境中不可用等:降级跳过,不打扰(E 场景)
|
|
212
|
-
cf_log.degrade(project_root, "stop_check", f"{validator.get('name')}:{exc}", sid)
|
|
271
|
+
argv = _validator_argv(project_root, validator, matched, strict)
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
failures.append(_validator_failure(validator, f"validator 配置错误: {exc}"))
|
|
213
274
|
continue
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
)
|
|
220
|
-
if not passed:
|
|
221
|
-
failures.append({
|
|
222
|
-
"name": validator.get("name", "validator"),
|
|
223
|
-
"on_fail": validator.get("on_fail", ""),
|
|
224
|
-
"detail": detail,
|
|
225
|
-
})
|
|
275
|
+
if not argv:
|
|
276
|
+
continue
|
|
277
|
+
errors, incomplete = _validator_result(project_root, validator, argv, sid, deadline, strict)
|
|
278
|
+
failures.extend(errors)
|
|
279
|
+
truncated = truncated or incomplete
|
|
226
280
|
return failures, truncated
|
|
227
281
|
|
|
228
282
|
|
|
229
283
|
def _reason_text(failures: list, truncated: bool) -> str:
|
|
230
284
|
lines = ["收尾校验未通过(cf-stop):"]
|
|
231
285
|
for item in failures:
|
|
232
|
-
|
|
286
|
+
tag = "(未执行)" if item.get("incomplete") else ""
|
|
287
|
+
lines.append(f"✗ {item['name']}{tag}:{item['on_fail']}")
|
|
233
288
|
if item["detail"]:
|
|
234
289
|
lines.append(f" 输出片段: {item['detail'][:200]}")
|
|
235
290
|
if truncated:
|
|
@@ -238,7 +293,7 @@ def _reason_text(failures: list, truncated: bool) -> str:
|
|
|
238
293
|
return "\n".join(lines)
|
|
239
294
|
|
|
240
295
|
|
|
241
|
-
def
|
|
296
|
+
def _main() -> None:
|
|
242
297
|
try:
|
|
243
298
|
ensure_utf8_io()
|
|
244
299
|
raw = sys.stdin.read()
|
|
@@ -258,11 +313,16 @@ def main() -> None:
|
|
|
258
313
|
marker = os.path.join(project_root, ".code-flow", ".active-task.json")
|
|
259
314
|
has_active = os.path.exists(marker)
|
|
260
315
|
files = []
|
|
316
|
+
# Single entry deadline constrains the whole chain (Done + validators),
|
|
317
|
+
# so long checks end as `incomplete` instead of being killed by the
|
|
318
|
+
# host without a verdict.
|
|
319
|
+
deadline = time.monotonic() + TOTAL_BUDGET_SECONDS
|
|
261
320
|
if has_active:
|
|
262
321
|
try:
|
|
263
322
|
active = load_active_task(project_root)
|
|
264
323
|
task_dir = os.path.join(project_root, active.task_dir)
|
|
265
|
-
|
|
324
|
+
gate_remaining = deadline - time.monotonic()
|
|
325
|
+
done = run_done_gate(project_root, task_dir, cheap=True, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
|
|
266
326
|
except (OSError, ValueError) as exc:
|
|
267
327
|
if enforcement == "required":
|
|
268
328
|
payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
|
|
@@ -284,13 +344,13 @@ def main() -> None:
|
|
|
284
344
|
if not resolve_quality_loop(config)["stop_check"]:
|
|
285
345
|
return
|
|
286
346
|
if not has_active:
|
|
287
|
-
files = session_edited_files(project_root, sid)
|
|
347
|
+
files = _root_scoped_files(project_root, session_edited_files(project_root, sid))
|
|
288
348
|
if not files:
|
|
289
349
|
return
|
|
290
350
|
acceptance_failures = task_acceptance_failures(project_root, files)
|
|
291
|
-
validators =
|
|
351
|
+
validators = _stop_validators(project_root)
|
|
292
352
|
failures, truncated = run_validators(
|
|
293
|
-
project_root, validators, files, sid
|
|
353
|
+
project_root, validators, files, sid, deadline=deadline
|
|
294
354
|
) if validators else ([], False)
|
|
295
355
|
failures = acceptance_failures + failures
|
|
296
356
|
if not failures:
|
|
@@ -306,9 +366,16 @@ def main() -> None:
|
|
|
306
366
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
307
367
|
except Exception as exc:
|
|
308
368
|
_log(f"cf_stop_hook error: {exc}")
|
|
369
|
+
if locals().get("enforcement") == "required":
|
|
370
|
+
sys.stdout.write(json.dumps({"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: {exc}"}, ensure_ascii=False))
|
|
309
371
|
return
|
|
310
372
|
|
|
311
373
|
|
|
374
|
+
def main() -> None:
|
|
375
|
+
with execution_session():
|
|
376
|
+
_main()
|
|
377
|
+
|
|
378
|
+
|
|
312
379
|
if __name__ == "__main__":
|
|
313
380
|
main()
|
|
314
381
|
timing_log("cf_stop_hook")
|