@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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/adapters/claude/commands/cf-init.md +1 -0
  4. package/src/adapters/claude/commands/cf-spec.md +7 -3
  5. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  6. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  7. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  8. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  9. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  10. package/src/adapters/claude/commands/cf-task/start.md +96 -25
  11. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  12. package/src/adapters/claude/commands/cf-validate.md +15 -13
  13. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  14. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  15. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  16. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  17. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  18. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  19. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
  21. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  22. package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
  23. package/src/adapters/costrict/commands/cf-init.md +1 -0
  24. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  25. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  27. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  29. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  30. package/src/adapters/costrict/commands/cf-task/start.md +96 -25
  31. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  32. package/src/adapters/costrict/commands/cf-validate.md +15 -13
  33. package/src/adapters/opencode/commands/cf-init.md +9 -4
  34. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  35. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  36. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  37. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  38. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  39. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  40. package/src/adapters/opencode/commands/cf-task/start.md +96 -25
  41. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  42. package/src/adapters/opencode/commands/cf-validate.md +15 -13
  43. package/src/adapters/opencode/opencode.json +1 -2
  44. package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
  45. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  46. package/src/cli.js +228 -64
  47. package/src/core/code-flow/config.yml +2 -1
  48. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  49. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
  50. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  51. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  52. package/src/core/code-flow/scripts/cf_core.py +34 -1
  53. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  54. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  55. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
  56. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  57. package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
  58. package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
  59. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  60. package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
  61. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  62. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  63. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  64. package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
  65. package/src/core/code-flow/scripts/cf_task_index.py +148 -0
  66. package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
  67. package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
  68. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  69. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  70. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  71. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  72. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  73. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
  74. 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 _status_issue(rule: RuleBinding, status: RuleStageStatus, now: datetime, diff_sha256: Optional[str]) -> Optional[str]:
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, stage: str, now: datetime, diff_sha256: Optional[str]
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
- return ("path", matched) if matched else ("global", ())
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 `cf-spec doctor` (resync) to re-sync the marker before continuing",
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
- match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:\s*(.+)$", section)
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
- try:
274
- completed = subprocess.run(
275
- argv,
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
- except OSError as exc:
285
- return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": str(exc)})
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.returncode,
289
- "stdout_sha256": _hash_bytes(completed.stdout.encode()),
290
- "stderr_sha256": _hash_bytes(completed.stderr.encode()),
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.returncode in allowed
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 run_all_verifiers(
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
- details = {"rule": rule.ref}
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 = sum(
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
- claude_path = os.path.join(project_root, "CLAUDE.md")
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": "CLAUDE.md", "tokens": l0_tokens, "budget": l0_budget},
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 (CLAUDE.md):", f"{l0_tokens} / {l0_budget}")
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
- if not status or status.group(1).strip() != "done":
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
- contract_ok = any(scenario in line and "verified" in line.lower()
132
- for line in contract.splitlines())
133
- evidence_ok = any(scenario in line and "verified" in line.lower()
134
- for line in evidence.splitlines())
135
- coverage_ok = any(scenario in line and "verified" in line.lower()
136
- for line in coverage.splitlines())
137
- if not contract_ok or not evidence_ok or not coverage_ok:
138
- return f"{task_id} 的 {scenario} 未在覆盖表、契约和证据中全部 verified"
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
- """Run matching validators serially → (failures, truncated)."""
174
- import time
175
- failures = []
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 - time.monotonic()
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
- proc = subprocess.run(
201
- command, shell=True, cwd=project_root,
202
- capture_output=True, text=True, timeout=timeout,
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
- cf_log.append_event(
215
- project_root, "stop_check",
216
- {"trigger": validator.get("trigger", ""),
217
- "cmd": str(validator.get("command", ""))[:120], "passed": passed},
218
- sid,
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
- lines.append(f"✗ {item['name']}:{item['on_fail']}")
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 main() -> None:
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
- done = run_done_gate(project_root, task_dir, budget=GATE_BUDGET_SECONDS)
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 = load_validators(project_root)
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")