@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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  3. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  4. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  5. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  6. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  7. package/src/adapters/claude/commands/cf-task/start.md +22 -22
  8. package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
  9. package/src/adapters/claude/commands/cf-validate.md +12 -13
  10. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  11. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  12. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  13. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  14. package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
  15. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  16. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  17. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  18. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  19. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  20. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  21. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  22. package/src/adapters/costrict/commands/cf-task/start.md +22 -22
  23. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
  24. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  25. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  27. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  29. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  30. package/src/adapters/opencode/commands/cf-task/start.md +22 -22
  31. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
  32. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  33. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  34. package/src/cli.js +177 -63
  35. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  36. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
  37. package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
  38. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  39. package/src/core/code-flow/scripts/cf_core.py +26 -0
  40. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  41. package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
  42. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
  43. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  44. package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
  45. package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
  46. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  47. package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
  48. package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
  49. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  50. package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
  51. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  52. package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
  53. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  54. package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
  55. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  56. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
  58. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  59. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  60. 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 = 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,
@@ -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
- if not status or status.group(1).strip() != "done":
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
- contract_ok = any(scenario in line and "verified" in line.lower()
131
- for line in contract.splitlines())
132
- evidence_ok = any(scenario in line and "verified" in line.lower()
133
- for line in evidence.splitlines())
134
- coverage_ok = any(scenario in line and "verified" in line.lower()
135
- for line in coverage.splitlines())
136
- if not contract_ok or not evidence_ok or not coverage_ok:
137
- return f"{task_id} 的 {scenario} 未在覆盖表、契约和证据中全部 verified"
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
- """Run matching validators serially → (failures, truncated)."""
173
- import time
174
- failures = []
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 - time.monotonic()
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
- proc = subprocess.run(
200
- command, shell=True, cwd=project_root,
201
- capture_output=True, text=True, timeout=timeout,
202
- )
203
- passed = proc.returncode == 0
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
- cf_log.append_event(
214
- project_root, "stop_check",
215
- {"trigger": validator.get("trigger", ""),
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
- lines.append(f"✗ {item['name']}:{item['on_fail']}")
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 main() -> None:
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
- done = run_done_gate(project_root, task_dir, budget=GATE_BUDGET_SECONDS)
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 run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None) -> DoneResult:
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
- return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
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)
@@ -0,0 +1,3 @@
1
+ """Shared lifecycle predicates without loading the workflow runtime."""
2
+
3
+ FINISHED_STATUSES = ("done", "verified")