@michaelxwb/code-flow 0.6.3 → 0.6.5

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 (54) 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 +2 -0
  6. package/src/adapters/claude/commands/cf-task/graph.md +1 -1
  7. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  8. package/src/adapters/claude/commands/cf-task/start.md +99 -10
  9. package/src/adapters/claude/commands/cf-task/verify-e2e.md +6 -1
  10. package/src/adapters/claude/commands/cf-validate.md +3 -0
  11. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  12. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  13. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +2 -0
  14. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +1 -1
  15. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  16. package/src/adapters/codex/skills/cf-task-start/SKILL.md +99 -10
  17. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +6 -1
  18. package/src/adapters/codex/skills/cf-validate/SKILL.md +3 -0
  19. package/src/adapters/costrict/commands/cf-init.md +1 -0
  20. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  21. package/src/adapters/costrict/commands/cf-task/archive.md +2 -0
  22. package/src/adapters/costrict/commands/cf-task/graph.md +1 -1
  23. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  24. package/src/adapters/costrict/commands/cf-task/start.md +99 -10
  25. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +6 -1
  26. package/src/adapters/costrict/commands/cf-validate.md +3 -0
  27. package/src/adapters/opencode/commands/cf-init.md +9 -4
  28. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  29. package/src/adapters/opencode/commands/cf-task/archive.md +2 -0
  30. package/src/adapters/opencode/commands/cf-task/graph.md +1 -1
  31. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  32. package/src/adapters/opencode/commands/cf-task/start.md +99 -10
  33. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +6 -1
  34. package/src/adapters/opencode/commands/cf-validate.md +3 -0
  35. package/src/adapters/opencode/opencode.json +1 -2
  36. package/src/adapters/opencode/plugins/code-flow/index.js +138 -78
  37. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  38. package/src/cli.js +51 -1
  39. package/src/core/code-flow/config.yml +2 -1
  40. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +54 -8
  41. package/src/core/code-flow/scripts/cf_core.py +28 -1
  42. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  43. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +14 -3
  44. package/src/core/code-flow/scripts/cf_spec_context.py +53 -16
  45. package/src/core/code-flow/scripts/cf_spec_gate.py +28 -4
  46. package/src/core/code-flow/scripts/cf_spec_metadata.py +34 -3
  47. package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
  48. package/src/core/code-flow/scripts/cf_spec_verify.py +107 -9
  49. package/src/core/code-flow/scripts/cf_stop_hook.py +31 -3
  50. package/src/core/code-flow/scripts/cf_task_index.py +5 -4
  51. package/src/core/code-flow/scripts/cf_task_parallel.py +394 -0
  52. package/src/core/code-flow/scripts/cf_task_runtime.py +49 -10
  53. package/src/core/code-flow/scripts/cf_task_workflow.py +111 -13
  54. package/src/core/code-flow/validation.yml +5 -0
@@ -42,6 +42,7 @@ DEFAULT_ACTIVE_EXCLUDES = (
42
42
  ".code-flow/.session-log.jsonl",
43
43
  ".code-flow/.active-task.json",
44
44
  ".code-flow/.active-task.lock",
45
+ ".code-flow/worktrees/*",
45
46
  )
46
47
 
47
48
 
@@ -100,6 +101,7 @@ class RuleBinding:
100
101
  enforcement: str
101
102
  verifier_ref: str
102
103
  stage_status: Mapping[str, RuleStageStatus]
104
+ verifier_stage: str = "code"
103
105
 
104
106
 
105
107
  @dataclass(frozen=True)
@@ -805,6 +807,7 @@ def _rule_from_data(value: object, path: str) -> RuleBinding:
805
807
  _string(data.get("enforcement"), "rules[].enforcement", path),
806
808
  _string(data.get("verifier_ref"), "rules[].verifier_ref", path),
807
809
  parsed,
810
+ _string(data.get("verifier_stage", "code"), "rules[].verifier_stage", path),
808
811
  )
809
812
 
810
813
 
@@ -815,6 +818,7 @@ def _rule_data(rule: RuleBinding) -> dict[str, object]:
815
818
  "text_sha256": rule.text_sha256,
816
819
  "enforcement": rule.enforcement,
817
820
  "verifier_ref": rule.verifier_ref,
821
+ "verifier_stage": rule.verifier_stage,
818
822
  "stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
819
823
  }
820
824
 
@@ -989,7 +993,12 @@ def _binding_from_input(selection: BindingInput) -> SpecBinding:
989
993
  }
990
994
  verifier = verifier_by_rule.get(rule.ref)
991
995
  verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
992
- rules.append(RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses))
996
+ rules.append(
997
+ RuleBinding(
998
+ rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
999
+ verifier.stage if verifier is not None else "code",
1000
+ )
1001
+ )
993
1002
  return SpecBinding(
994
1003
  candidate.spec_id,
995
1004
  candidate.path,
@@ -1002,12 +1011,26 @@ def _binding_from_input(selection: BindingInput) -> SpecBinding:
1002
1011
  )
1003
1012
 
1004
1013
 
1014
+ def _with_declared_stages(rule: RuleBinding, stages: Sequence[str]) -> RuleBinding:
1015
+ """兼容补齐:绑定旧版本时声明的 stages 缺状态,按声明补 pending。"""
1016
+ missing = [stage for stage in stages if stage not in rule.stage_status]
1017
+ if not missing:
1018
+ return rule
1019
+ statuses = dict(rule.stage_status)
1020
+ for stage in missing:
1021
+ statuses[stage] = RuleStageStatus("pending", (), None, ())
1022
+ return replace(rule, stage_status=statuses)
1023
+
1024
+
1005
1025
  def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
1006
1026
  enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
1007
1027
  statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
1008
- has_verifier = any(item.rule == rule.ref for item in metadata.verifiers)
1009
- verifier_ref = f"{spec_id}#{rule.ref}" if has_verifier else "advisory:none"
1010
- return RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses)
1028
+ verifier = next((item for item in metadata.verifiers if item.rule == rule.ref), None)
1029
+ verifier_ref = f"{spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
1030
+ return RuleBinding(
1031
+ rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
1032
+ verifier.stage if verifier is not None else "code",
1033
+ )
1011
1034
 
1012
1035
 
1013
1036
  def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
@@ -1155,14 +1178,15 @@ def _refresh_existing_rule(
1155
1178
  binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
1156
1179
  ) -> tuple[RuleBinding, Optional[DriftChange]]:
1157
1180
  enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
1158
- has_verifier = any(item.rule == current.ref for item in metadata.verifiers)
1159
- verifier_ref = f"{binding.spec_id}#{current.ref}" if has_verifier else "advisory:none"
1181
+ verifier = next((item for item in metadata.verifiers if item.rule == current.ref), None)
1182
+ verifier_ref = f"{binding.spec_id}#{current.ref}" if verifier is not None else "advisory:none"
1160
1183
  updated = replace(
1161
1184
  existing,
1162
1185
  summary=current.text,
1163
1186
  text_sha256=current.text_sha256,
1164
1187
  enforcement=enforcement,
1165
1188
  verifier_ref=verifier_ref,
1189
+ verifier_stage=verifier.stage if verifier is not None else "code",
1166
1190
  )
1167
1191
  if existing.text_sha256 == current.text_sha256:
1168
1192
  return updated, None
@@ -1199,6 +1223,7 @@ def _refresh_binding(
1199
1223
  rule, change = _refresh_existing_rule(binding, prior, current, metadata)
1200
1224
  if change is not None:
1201
1225
  changes.append(change)
1226
+ rule = _with_declared_stages(rule, metadata.stages)
1202
1227
  rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
1203
1228
  rules.append(rule)
1204
1229
  changes.extend(artifact_changes)
@@ -1314,8 +1339,15 @@ def apply_artifact_ref(
1314
1339
 
1315
1340
  def _json_payload(stream: IO[str]) -> Mapping[str, object]:
1316
1341
  try:
1317
- loaded = json.load(stream)
1318
- except (json.JSONDecodeError, UnicodeError) as exc:
1342
+ raw = stream.read()
1343
+ except (OSError, UnicodeError) as exc:
1344
+ raise ContextError("invalid_json", "stdin", str(exc)) from exc
1345
+ if not raw.strip():
1346
+ # 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
1347
+ return {}
1348
+ try:
1349
+ loaded = json.loads(raw)
1350
+ except json.JSONDecodeError as exc:
1319
1351
  raise ContextError("invalid_json", "stdin", str(exc)) from exc
1320
1352
  return _mapping(loaded, "stdin", "")
1321
1353
 
@@ -1508,12 +1540,11 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
1508
1540
  except WorkflowError as exc:
1509
1541
  raise _reraise(exc) from exc
1510
1542
  else:
1511
- result = doctor_active_task(
1512
- args.root,
1513
- args.context_sha256,
1514
- payload.get("abandon") is True,
1515
- payload.get("resync") is True,
1516
- )
1543
+ abandon = payload.get("abandon") is True or args.abandon
1544
+ resync = payload.get("resync") is True or args.resync
1545
+ if abandon and resync:
1546
+ raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
1547
+ result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
1517
1548
  return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1518
1549
  return {"ok": True, "active": _active_data(active)}
1519
1550
 
@@ -1564,11 +1595,13 @@ def _status_command(args: argparse.Namespace) -> dict[str, object]:
1564
1595
  rules = []
1565
1596
  for rule in binding.rules:
1566
1597
  status = rule.stage_status.get("code")
1598
+ stages = {stage: item.status for stage, item in sorted(rule.stage_status.items())}
1567
1599
  rules.append(
1568
1600
  {
1569
1601
  "ref": f"{binding.spec_id}#{rule.ref}",
1570
1602
  "enforcement": rule.enforcement,
1571
1603
  "status": status.status if status else "missing",
1604
+ "stages": stages,
1572
1605
  "verifier": rule.verifier_ref,
1573
1606
  }
1574
1607
  )
@@ -1589,7 +1622,7 @@ def _status_text(data: dict[str, object]) -> str:
1589
1622
  match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
1590
1623
  lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
1591
1624
  if not marker["hash_match"]:
1592
- lines.append(" 下一步: 运行 cf-spec doctor(resync 可自动重同步)")
1625
+ lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
1593
1626
  else:
1594
1627
  lines.append("- 无 active TASK(path/catalog 路由模式)")
1595
1628
  gate = data["gate"]
@@ -1602,7 +1635,8 @@ def _status_text(data: dict[str, object]) -> str:
1602
1635
  for binding in data["bindings"]:
1603
1636
  lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
1604
1637
  for rule in binding["rules"]:
1605
- lines.append(f" {rule['status']:<12} {rule['ref']}({rule['verifier']})")
1638
+ stage_text = " ".join(f"{stage}={value}" for stage, value in rule.get("stages", {}).items())
1639
+ lines.append(f" {stage_text:<24} {rule['ref']}({rule['verifier']})")
1606
1640
  return "\n".join(lines)
1607
1641
 
1608
1642
 
@@ -1643,6 +1677,9 @@ def _parser() -> argparse.ArgumentParser:
1643
1677
  command.add_argument("--task", required=True)
1644
1678
  command.add_argument("--context-sha256", required=True)
1645
1679
  command.add_argument("--json", action="store_true")
1680
+ if action == "doctor":
1681
+ command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
1682
+ command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
1646
1683
  start = commands.add_parser("start")
1647
1684
  start.add_argument("--task-dir", required=True)
1648
1685
  start.add_argument("--root", required=True)
@@ -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] = []
@@ -95,9 +116,11 @@ def _binding_issues(
95
116
  status = rule.stage_status.get(stage)
96
117
  if status is None:
97
118
  continue
119
+ if stage in ("code", "review") and getattr(rule, "verifier_stage", "code") != stage:
120
+ continue
98
121
  if rule.enforcement == "advisory" and status.status == "pending":
99
122
  continue
100
- code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256)
123
+ code = "missing_spec" if binding.status == "missing" else _status_issue(rule, status, now, diff_sha256, allow_cheap_skips)
101
124
  if code is None:
102
125
  continue
103
126
  issue = GateIssue(code, binding.spec_id, rule.ref, stage, f"{binding.spec_id}#{rule.ref}: {code}")
@@ -113,13 +136,14 @@ def validate_stage(
113
136
  task_id: str = "",
114
137
  now: Optional[datetime] = None,
115
138
  diff_sha256: Optional[str] = None,
139
+ allow_cheap_skips: bool = False,
116
140
  ) -> GateResult:
117
141
  del artifact, task_id
118
142
  current = now or datetime.now(timezone.utc)
119
143
  errors: list[GateIssue] = []
120
144
  warnings: list[GateIssue] = []
121
145
  for binding in context.bindings:
122
- binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256)
146
+ binding_errors, binding_warnings = _binding_issues(binding, stage, current, diff_sha256, allow_cheap_skips)
123
147
  errors.extend(binding_errors)
124
148
  warnings.extend(binding_warnings)
125
149
  refs = tuple(sorted({f"{item.spec_id}#{item.rule_ref}" for item in (*errors, *warnings)}))
@@ -15,6 +15,7 @@ from typing import Mapping, Optional
15
15
  ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
16
16
  ALLOWED_ENFORCEMENT = frozenset(("required", "advisory"))
17
17
  ALLOWED_VERIFIERS = frozenset(("document", "regex", "ast", "command", "test", "manual"))
18
+ ALLOWED_VERIFIER_STAGES = frozenset(("code", "review"))
18
19
  _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
19
20
  _RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
20
21
  _EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
@@ -52,6 +53,8 @@ class SpecVerifier:
52
53
  rule: str
53
54
  type: str
54
55
  config: Mapping[str, object]
56
+ stage: str = "code"
57
+ files: tuple[str, ...] = ()
55
58
 
56
59
 
57
60
  @dataclass(frozen=True)
@@ -209,7 +212,26 @@ def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[M
209
212
  return tuple(value)
210
213
 
211
214
 
212
- def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[SpecVerifier, ...]:
215
+ def _parse_verifier_files(value: object, path: str, raw: str, index: int) -> tuple[str, ...]:
216
+ line = _verifier_field_line(raw, index, "files")
217
+ if not isinstance(value, list) or not value:
218
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, "必须是非空 glob 字符串数组")
219
+ files: list[str] = []
220
+ for item in value:
221
+ if not isinstance(item, str) or not item.strip():
222
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, "glob 必须是非空字符串")
223
+ text = item.strip().replace("\\", "/")
224
+ if text.startswith("/") or re.match(r"^[A-Za-z]:/", text):
225
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止绝对路径: {item!r}")
226
+ if ".." in text.split("/"):
227
+ raise SpecMetadataError(path, f"verifiers[{index}].files", line, f"禁止 .. 跳出仓库: {item!r}")
228
+ files.append(text)
229
+ return tuple(files)
230
+
231
+
232
+ def _parse_verifiers(
233
+ meta: Mapping[str, object], path: str, raw: str, stages: tuple[str, ...]
234
+ ) -> tuple[SpecVerifier, ...]:
213
235
  value = meta.get("verifiers", [])
214
236
  if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
215
237
  raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), "必须是 mapping 列表")
@@ -219,6 +241,8 @@ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[S
219
241
  rule = item.get("rule")
220
242
  kind = item.get("type")
221
243
  config = item.get("config")
244
+ explicit_stage = "stage" in item
245
+ stage = item.get("stage", "code")
222
246
  if not isinstance(rule, str) or not _RULE_ID_RE.fullmatch(rule):
223
247
  line = _verifier_field_line(raw, index, "rule")
224
248
  raise SpecMetadataError(path, f"verifiers[{index}].rule", line, "必须是稳定 RULE ID")
@@ -228,10 +252,17 @@ def _parse_verifiers(meta: Mapping[str, object], path: str, raw: str) -> tuple[S
228
252
  if not isinstance(config, dict):
229
253
  line = _verifier_field_line(raw, index, "config")
230
254
  raise SpecMetadataError(path, f"verifiers[{index}].config", line, "必须是 mapping")
255
+ if explicit_stage and (not isinstance(stage, str) or stage not in ALLOWED_VERIFIER_STAGES):
256
+ line = _verifier_field_line(raw, index, "stage")
257
+ raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"必须是 {sorted(ALLOWED_VERIFIER_STAGES)} 之一")
258
+ if explicit_stage and stage not in stages:
259
+ line = _verifier_field_line(raw, index, "stage")
260
+ raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"stage {stage!r} 未在 stages 声明: {', '.join(stages)}")
261
+ files = _parse_verifier_files(item["files"], path, raw, index) if "files" in item else ()
231
262
  if rule in seen:
232
263
  raise SpecMetadataError(path, "verifiers", _field_line(raw, "verifiers"), f"重复 verifier: {rule}")
233
264
  seen.add(rule)
234
- result.append(SpecVerifier(rule=rule, type=kind, config=config))
265
+ result.append(SpecVerifier(rule=rule, type=kind, config=config, stage=stage, files=files))
235
266
  return tuple(result)
236
267
 
237
268
 
@@ -324,7 +355,7 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
324
355
  if owner is not None and (not isinstance(owner, str) or not owner.strip()):
325
356
  raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
326
357
  checks = _validate_checks(meta, path, raw_frontmatter)
327
- verifiers = _parse_verifiers(meta, path, raw_frontmatter)
358
+ verifiers = _parse_verifiers(meta, path, raw_frontmatter, header.stages)
328
359
  body_line_offset = raw_frontmatter.count("\n") + 3
329
360
  rules = _parse_rules(body, path, body_line_offset)
330
361
  _validate_links(rules, verifiers, path, raw_frontmatter)
@@ -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:
@@ -4,7 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import ast
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, replace
8
8
  from datetime import datetime, timezone
9
9
  import fnmatch
10
10
  import hashlib
@@ -59,19 +59,94 @@ def _cache_file(root: str) -> Path:
59
59
  return Path(root) / ".code-flow" / ".verifier-cache.json"
60
60
 
61
61
 
62
- def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]]) -> str:
62
+ def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]], stage: str = "code") -> str:
63
63
  payload = {
64
64
  "spec": metadata.hashes.file_sha256,
65
65
  "rule": rule.text_sha256,
66
66
  "verifier": {"type": verifier.type, "config": verifier.config},
67
67
  "files": scope.files,
68
68
  "diff": scope.diff_sha256,
69
+ "stage": stage,
69
70
  "confirmation": confirmation or {},
70
71
  "artifact_content": _artifact_fingerprint(verifier, scope),
71
72
  }
72
73
  return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
73
74
 
74
75
 
76
+ def _normalize_scope_path(value: str) -> str:
77
+ return value.replace("\\", "/").strip()
78
+
79
+
80
+ def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
81
+ for pattern in patterns:
82
+ if fnmatch.fnmatchcase(path, pattern):
83
+ return True
84
+ bare = pattern.rstrip("/")
85
+ if not any(char in pattern for char in "*?[") and path.startswith(bare + "/"):
86
+ return True
87
+ return False
88
+
89
+
90
+ def _git_tracked_files(root: str) -> tuple[str, ...]:
91
+ try:
92
+ outcome = subprocess.run(
93
+ ("git", "ls-files"), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
94
+ )
95
+ except OSError:
96
+ return ()
97
+ if outcome.returncode != 0:
98
+ return ()
99
+ return tuple(_normalize_scope_path(line) for line in outcome.stdout.splitlines() if line.strip())
100
+
101
+
102
+ def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
103
+ """作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
104
+
105
+ 决议仓库级集合(而非仅当前任务 diff)才能让未触及作用域的其他任务命中缓存。
106
+ """
107
+ if not verifier.files:
108
+ return ()
109
+ candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
110
+ candidates.update(_git_tracked_files(scope.root))
111
+ return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
112
+
113
+
114
+ def _scope_fingerprint(root: str, files: Sequence[str]) -> str:
115
+ digest = hashlib.sha256()
116
+ for relative in files:
117
+ path = Path(root) / relative
118
+ digest.update(relative.encode("utf-8"))
119
+ try:
120
+ digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
121
+ except OSError:
122
+ digest.update(b"<unreadable>")
123
+ return digest.hexdigest()
124
+
125
+
126
+ def _scoped_cache_key(
127
+ metadata: SpecMetadata,
128
+ rule: SpecRule,
129
+ verifier: SpecVerifier,
130
+ scope: VerificationScope,
131
+ confirmation: Optional[Mapping[str, object]],
132
+ stage: str,
133
+ ) -> Optional[str]:
134
+ """command/test 仅在声明 files 作用域时缓存;未声明保持全量执行。"""
135
+ if verifier.type not in ("command", "test") or not verifier.files:
136
+ return None
137
+ files = scoped_files(verifier, scope)
138
+ payload = {
139
+ "spec": metadata.hashes.file_sha256,
140
+ "rule": rule.text_sha256,
141
+ "verifier": {"type": verifier.type, "config": verifier.config, "files": list(verifier.files)},
142
+ "stage": stage,
143
+ "scope_files": files,
144
+ "scope_sha256": _scope_fingerprint(scope.root, files),
145
+ "confirmation": confirmation or {},
146
+ }
147
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
148
+
149
+
75
150
  def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
76
151
  if verifier.type != "document":
77
152
  return None
@@ -143,13 +218,32 @@ def _cached_or_run(
143
218
  verifier: SpecVerifier,
144
219
  scope: VerificationScope,
145
220
  confirmation: Optional[Mapping[str, object]],
221
+ stage: str = "code",
146
222
  ) -> VerificationEvidence:
147
223
  cacheable = verifier.type in _PURE_VERIFIER_TYPES
148
- key = _cache_key(metadata, rule, verifier, scope, confirmation) if cacheable else ""
224
+ scoped_key: Optional[str] = None
225
+ if cacheable:
226
+ key = _cache_key(metadata, rule, verifier, scope, confirmation, stage)
227
+ else:
228
+ scoped_key = _scoped_cache_key(metadata, rule, verifier, scope, confirmation, stage)
229
+ if scoped_key is None:
230
+ key = ""
231
+ else:
232
+ cacheable, key = True, scoped_key
149
233
  if cacheable:
150
234
  cached = _cached_evidence(key)
151
235
  if cached is not None:
152
- return cached
236
+ if scoped_key is None:
237
+ return cached
238
+ details = dict(cached.details or {})
239
+ details["cache_reused"] = True
240
+ details["scope_files"] = list(scoped_files(verifier, scope))
241
+ return replace(
242
+ cached,
243
+ executed_at=datetime.now(timezone.utc).isoformat(),
244
+ diff_sha256=scope.diff_sha256,
245
+ details=details,
246
+ )
153
247
  current = _evidence(metadata, rule, verifier, scope, confirmation)
154
248
  if cacheable and current.status == "verified":
155
249
  _save_result_cache(key, current)
@@ -400,6 +494,7 @@ def _run_all_verifiers(
400
494
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
401
495
  skip_command: bool = False,
402
496
  timeout_budget: Optional[float] = None,
497
+ stage: str = "code",
403
498
  ) -> VerificationResult:
404
499
  verifier_by_rule = {item.rule: item for item in metadata.verifiers}
405
500
  confirmation_by_rule = confirmations or {}
@@ -410,12 +505,14 @@ def _run_all_verifiers(
410
505
  for rule in metadata.rules:
411
506
  if rule.enforcement != "required":
412
507
  continue
508
+ verifier = verifier_by_rule.get(rule.ref)
509
+ if verifier is not None and verifier.stage != stage:
510
+ continue
413
511
  if timeout_budget is not None:
414
512
  remaining = timeout_budget - (time.monotonic() - started)
415
513
  if remaining <= 0:
416
514
  evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
417
515
  continue
418
- verifier = verifier_by_rule.get(rule.ref)
419
516
  if verifier is None:
420
517
  evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
421
518
  continue
@@ -427,11 +524,11 @@ def _run_all_verifiers(
427
524
  parallel.append((rule, verifier, confirmation))
428
525
  continue
429
526
  cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
430
- evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
527
+ evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation, stage) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
431
528
  if parallel:
432
529
  with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
433
530
  futures = {
434
- rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation)
531
+ rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
435
532
  for rule, verifier, confirmation in parallel
436
533
  }
437
534
  evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
@@ -442,9 +539,10 @@ def _run_all_verifiers(
442
539
 
443
540
  def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
444
541
  confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
445
- skip_command: bool = False, timeout_budget: Optional[float] = None) -> VerificationResult:
542
+ skip_command: bool = False, timeout_budget: Optional[float] = None,
543
+ stage: str = "code") -> VerificationResult:
446
544
  with execution_session():
447
- return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget)
545
+ return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage)
448
546
 
449
547
 
450
548
  def evidence_is_fresh(
@@ -96,6 +96,34 @@ def session_edited_files(project_root: str, sid: str) -> list:
96
96
  return files
97
97
 
98
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
+
99
127
  def _is_active_task_file(rel_path: str) -> bool:
100
128
  path = normalize_path(rel_path)
101
129
  if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
@@ -294,7 +322,7 @@ def _main() -> None:
294
322
  active = load_active_task(project_root)
295
323
  task_dir = os.path.join(project_root, active.task_dir)
296
324
  gate_remaining = deadline - time.monotonic()
297
- done = run_done_gate(project_root, task_dir, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
325
+ done = run_done_gate(project_root, task_dir, cheap=True, budget=min(GATE_BUDGET_SECONDS, max(gate_remaining, 0.01)))
298
326
  except (OSError, ValueError) as exc:
299
327
  if enforcement == "required":
300
328
  payload = {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
@@ -316,11 +344,11 @@ def _main() -> None:
316
344
  if not resolve_quality_loop(config)["stop_check"]:
317
345
  return
318
346
  if not has_active:
319
- files = session_edited_files(project_root, sid)
347
+ files = _root_scoped_files(project_root, session_edited_files(project_root, sid))
320
348
  if not files:
321
349
  return
322
350
  acceptance_failures = task_acceptance_failures(project_root, files)
323
- validators = load_validators(project_root)
351
+ validators = _stop_validators(project_root)
324
352
  failures, truncated = run_validators(
325
353
  project_root, validators, files, sid, deadline=deadline
326
354
  ) if validators else ([], False)
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """Programmatic task-file index: parse TASK statuses/depends, compute DAG batches.
3
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.
4
+ Single worktrees allow one active TASK at a time; batches list *independently
5
+ developable* tasks (no dependency path between them). `cf-task:start` may
6
+ dispatch batch tasks in their own worktrees for parallel subagents — the
7
+ single-active constraint stays per worktree.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -116,7 +117,7 @@ def index_data(task_file: str) -> dict[str, object]:
116
117
  "batches": task_batches(nodes),
117
118
  "independent_groups": independent_groups(nodes),
118
119
  "dependency_components": dependency_components(nodes),
119
- "note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK",
120
+ "note": "batches 组内无依赖;dependency_components 仅组间独立(independent_groups 为兼容别名);单 worktree 一次仅激活一个 TASK,批次内独立任务可由 cf-task:start 在各 worktree 并行派发",
120
121
  }
121
122
 
122
123