@michaelxwb/code-flow 0.6.3 → 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 (43) 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/graph.md +1 -1
  6. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  7. package/src/adapters/claude/commands/cf-task/start.md +80 -10
  8. package/src/adapters/claude/commands/cf-validate.md +3 -0
  9. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  10. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  11. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +1 -1
  12. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  13. package/src/adapters/codex/skills/cf-task-start/SKILL.md +80 -10
  14. package/src/adapters/codex/skills/cf-validate/SKILL.md +3 -0
  15. package/src/adapters/costrict/commands/cf-init.md +1 -0
  16. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  17. package/src/adapters/costrict/commands/cf-task/graph.md +1 -1
  18. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  19. package/src/adapters/costrict/commands/cf-task/start.md +80 -10
  20. package/src/adapters/costrict/commands/cf-validate.md +3 -0
  21. package/src/adapters/opencode/commands/cf-init.md +9 -4
  22. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  23. package/src/adapters/opencode/commands/cf-task/graph.md +1 -1
  24. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  25. package/src/adapters/opencode/commands/cf-task/start.md +80 -10
  26. package/src/adapters/opencode/commands/cf-validate.md +3 -0
  27. package/src/adapters/opencode/opencode.json +1 -2
  28. package/src/adapters/opencode/plugins/code-flow/index.js +138 -78
  29. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  30. package/src/cli.js +51 -1
  31. package/src/core/code-flow/config.yml +2 -1
  32. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +54 -8
  33. package/src/core/code-flow/scripts/cf_core.py +28 -1
  34. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  35. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +14 -3
  36. package/src/core/code-flow/scripts/cf_spec_context.py +19 -9
  37. package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
  38. package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
  39. package/src/core/code-flow/scripts/cf_stop_hook.py +31 -3
  40. package/src/core/code-flow/scripts/cf_task_index.py +5 -4
  41. package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
  42. package/src/core/code-flow/scripts/cf_task_runtime.py +38 -4
  43. package/src/core/code-flow/validation.yml +5 -0
package/src/cli.js CHANGED
@@ -237,6 +237,56 @@ function mergeSettingsJson(srcFile, destFile) {
237
237
  return added;
238
238
  }
239
239
 
240
+ // OpenCode v2 merge: migrate the v1 `plugin` key to `plugins`, drop the
241
+ // auto-discovered local path (`.opencode/plugins/*` loads without config),
242
+ // and union template `plugins` entries without overwriting user content.
243
+ function mergeOpencodeJson(srcFile, destFile) {
244
+ const src = JSON.parse(fs.readFileSync(srcFile, 'utf8'));
245
+ const dest = JSON.parse(fs.readFileSync(destFile, 'utf8'));
246
+ const added = [];
247
+ const isLocalEntry = (e) => {
248
+ const p = typeof e === 'string' ? e : (e && e.package);
249
+ return typeof p === 'string' && /(^|\/)\.opencode\/plugins\//.test(p);
250
+ };
251
+ if (Array.isArray(dest.plugin)) {
252
+ const carried = dest.plugin.filter((e) => !isLocalEntry(e));
253
+ delete dest.plugin;
254
+ added.push(`removed legacy 'plugin' key (v1 → v2)`);
255
+ if (carried.length > 0 && !Array.isArray(dest.plugins)) dest.plugins = carried;
256
+ } else if ('plugin' in dest) {
257
+ delete dest.plugin;
258
+ added.push(`removed legacy 'plugin' key (v1 → v2)`);
259
+ }
260
+ if (Array.isArray(dest.plugins)) {
261
+ const before = dest.plugins.length;
262
+ dest.plugins = dest.plugins.filter((e) => !isLocalEntry(e));
263
+ if (dest.plugins.length !== before) added.push('removed auto-discovered local plugin entry');
264
+ }
265
+ if (Array.isArray(src.plugins)) {
266
+ if (!Array.isArray(dest.plugins)) dest.plugins = [];
267
+ const seen = new Set(dest.plugins.map((e) => JSON.stringify(e)));
268
+ for (const entry of src.plugins) {
269
+ const key = JSON.stringify(entry);
270
+ if (!seen.has(key)) {
271
+ dest.plugins.push(entry);
272
+ seen.add(key);
273
+ added.push(`plugins +${typeof entry === 'string' ? entry : key}`.slice(0, 80));
274
+ }
275
+ }
276
+ }
277
+ for (const key of Object.keys(src)) {
278
+ if (key === 'plugins' || key === 'plugin') continue;
279
+ if (!(key in dest)) {
280
+ dest[key] = src[key];
281
+ added.push(key);
282
+ }
283
+ }
284
+ if (added.length > 0) {
285
+ fs.writeFileSync(destFile, JSON.stringify(dest, null, 2) + '\n');
286
+ }
287
+ return added;
288
+ }
289
+
240
290
  function mergeConfigYml(srcFile, destFile) {
241
291
  const srcText = fs.readFileSync(srcFile, 'utf8');
242
292
  const destText = fs.readFileSync(destFile, 'utf8');
@@ -781,7 +831,7 @@ function runInit(force, platform, opts) {
781
831
  dest: path.join(cwd, 'opencode.json'),
782
832
  label: 'opencode.json',
783
833
  mode: platformMode,
784
- mergeFn: mergeSettingsJson,
834
+ mergeFn: mergeOpencodeJson,
785
835
  toolOnUpgrade: false,
786
836
  results,
787
837
  });
@@ -24,7 +24,8 @@ budget:
24
24
  quality_loop:
25
25
  enabled: true
26
26
  post_check: true # PostToolUse 违规反馈(只提示不阻断)
27
- stop_check: true # Stop 收尾按 validation.yml 校验
27
+ stop_check: true # Stop 收尾按 validation.yml 校验(heavy 项跳过)
28
+ finish_check: true # 任务 finish 时全量执行 validation.yml(含 heavy,每任务一次)
28
29
  correction_capture: true # 纠正句式采集(喂 cf-learn 候选)
29
30
  # 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
30
31
  # 缺省视为 true;仅显式 false 关闭
@@ -10,7 +10,7 @@ import shlex
10
10
  from pathlib import Path
11
11
  import re
12
12
  import sys
13
- from typing import IO, Mapping, Optional, Sequence
13
+ from typing import IO, Callable, Mapping, Optional, Sequence
14
14
 
15
15
  from cf_acceptance_schema import load_manifest, validate_scenarios
16
16
 
@@ -26,12 +26,18 @@ def _kind(level: str) -> str:
26
26
  - functional: auto-executed in Done Gate (unit, integration)
27
27
  - e2e: deferred until explicit --verify-e2e (needs external dependencies)
28
28
  - manual: user confirmation required, never auto-executed
29
+
30
+ 层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
31
+ 取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
29
32
  """
30
- normalized = level.strip().lower()
31
- if normalized == "manual":
33
+ head = level.strip().lower()
34
+ for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
35
+ head = head.split(separator, 1)[0]
36
+ head = head.strip()
37
+ if head in ("manual", "手工", "手动"):
32
38
  return "manual"
33
- if normalized == "e2e":
34
- return "e2e" # Keep as separate kind, not functional
39
+ if head in ("e2e", "端到端", "end"):
40
+ return "e2e"
35
41
  return "functional"
36
42
 
37
43
 
@@ -70,7 +76,17 @@ def _manifest_hash(rows: list[dict[str, object]]) -> str:
70
76
  return hashlib.sha256(payload).hexdigest()
71
77
 
72
78
 
73
- def extract_manifest(task_file: str) -> dict[str, object]:
79
+ def _legacy_kind(level: str) -> str:
80
+ """v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
81
+ normalized = level.strip().lower()
82
+ if normalized == "manual":
83
+ return "manual"
84
+ if normalized == "e2e":
85
+ return "e2e"
86
+ return "functional"
87
+
88
+
89
+ def _extract_rows(task_file: str, kind_fn: Callable[[str], str]) -> dict[str, object]:
74
90
  path = Path(task_file)
75
91
  text = path.read_text(encoding="utf-8")
76
92
  rows: list[dict[str, object]] = []
@@ -98,7 +114,7 @@ def extract_manifest(task_file: str) -> dict[str, object]:
98
114
  "id": match.group(1),
99
115
  "source": fields[0],
100
116
  "level": fields[1],
101
- "kind": _kind(fields[1]),
117
+ "kind": kind_fn(fields[1]),
102
118
  "boundary": fields[2],
103
119
  "owner": fields[3],
104
120
  "status": fields[4],
@@ -113,6 +129,10 @@ def extract_manifest(task_file: str) -> dict[str, object]:
113
129
  return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
114
130
 
115
131
 
132
+ def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = _kind) -> dict[str, object]:
133
+ return _extract_rows(task_file, kind_fn)
134
+
135
+
116
136
  def write_manifest(task_file: str, output: str) -> dict[str, object]:
117
137
  manifest = extract_manifest(task_file)
118
138
  target = Path(output)
@@ -130,14 +150,40 @@ _IMMUTABLE_SCENARIO_FIELDS = (
130
150
  )
131
151
 
132
152
 
153
+ def _heal_legacy_kinds(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
154
+ """旧版 `_kind` 精确匹配把带注解的 E2E 锁成 functional;任务内容未变时
155
+ 原地升级 kind 并重锁哈希,保留 execution evidence 等运行态字段。"""
156
+ by_id: dict[str, dict[str, object]] = {}
157
+ scenarios = expected.get("scenarios")
158
+ if isinstance(scenarios, list):
159
+ for item in scenarios:
160
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
161
+ by_id[item["id"]] = item
162
+ current = manifest.get("scenarios")
163
+ if isinstance(current, list):
164
+ for item in current:
165
+ if not isinstance(item, dict):
166
+ continue
167
+ target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
168
+ if target is not None:
169
+ item["kind"] = target.get("kind")
170
+ manifest["task_sha256"] = expected["task_sha256"]
171
+ manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
172
+
173
+
133
174
  def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
134
175
  try:
135
176
  manifest = load_manifest(Path(manifest_file))
136
177
  expected = extract_manifest(task_file)
137
178
  except (OSError, ValueError, json.JSONDecodeError) as exc:
138
179
  return False, f"acceptance_manifest_invalid: {exc}"
139
- if manifest.get("schema") != _MANIFEST_SCHEMA or manifest.get("task_sha256") != expected["task_sha256"]:
180
+ if manifest.get("schema") != _MANIFEST_SCHEMA:
140
181
  return False, "acceptance_manifest_drift"
182
+ if manifest.get("task_sha256") != expected["task_sha256"]:
183
+ legacy = extract_manifest(task_file, _legacy_kind)
184
+ if manifest.get("task_sha256") != legacy["task_sha256"]:
185
+ return False, "acceptance_manifest_drift"
186
+ _heal_legacy_kinds(Path(manifest_file), manifest, expected)
141
187
  stored_task = manifest.get("task_file", "")
142
188
  if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
143
189
  return False, "acceptance_manifest_task_mismatch"
@@ -55,6 +55,31 @@ def normalize_path(path: str) -> str:
55
55
  return path.replace("\\", "/")
56
56
 
57
57
 
58
+ def effective_project_root(project_root: str, absolute_path: str) -> str:
59
+ """Resolve the project root that owns an edited file.
60
+
61
+ Parallel subagents edit `.code-flow/worktrees/<run>/<TASK>/...`; the nearest
62
+ `.code-flow` ancestor is that worktree's root, not the session root. Files
63
+ outside the session root and unresolvable paths fall back to `project_root`.
64
+ Lexical `abspath` (not `realpath`) keeps caller/returned paths in the same
65
+ coordinates (macOS /var ↔ /private/var symlink).
66
+ """
67
+ root = os.path.abspath(project_root)
68
+ target = os.path.abspath(absolute_path) if absolute_path else root
69
+ if target != root and not target.startswith(root + os.sep):
70
+ return root
71
+ current = target
72
+ while True:
73
+ if os.path.isdir(os.path.join(current, ".code-flow")):
74
+ return current
75
+ if current == root:
76
+ return root
77
+ parent = os.path.dirname(current)
78
+ if parent == current:
79
+ return root
80
+ current = parent
81
+
82
+
58
83
  def _spec_path_from_entry(entry) -> str:
59
84
  cfg = normalize_spec_entry(entry)
60
85
  return normalize_path(cfg.get("path", ""))
@@ -474,7 +499,8 @@ def resolve_quality_loop(config: dict) -> dict:
474
499
  """
475
500
  if not isinstance(config, dict):
476
501
  return {"enabled": False, "post_check": False,
477
- "stop_check": False, "correction_capture": False,
502
+ "stop_check": False, "finish_check": False,
503
+ "correction_capture": False,
478
504
  "compress_reminder": False}
479
505
  cfg = config.get("quality_loop")
480
506
  cfg = cfg if isinstance(cfg, dict) else {}
@@ -487,6 +513,7 @@ def resolve_quality_loop(config: dict) -> dict:
487
513
  "enabled": enabled,
488
514
  "post_check": _sub("post_check"),
489
515
  "stop_check": _sub("stop_check"),
516
+ "finish_check": _sub("finish_check"),
490
517
  "correction_capture": _sub("correction_capture"),
491
518
  "compress_reminder": _sub("compress_reminder"),
492
519
  }
@@ -31,6 +31,7 @@ from cf_checks import (
31
31
  from cf_core import (
32
32
  _log,
33
33
  build_effective_mapping,
34
+ effective_project_root,
34
35
  ensure_utf8_io,
35
36
  is_code_file,
36
37
  load_config,
@@ -107,7 +108,10 @@ def main() -> None:
107
108
  if not isinstance(file_path, str) or not file_path:
108
109
  return
109
110
 
110
- project_root = os.getcwd()
111
+ session_root = os.getcwd()
112
+ abs_path = file_path if os.path.isabs(file_path) else os.path.join(session_root, file_path)
113
+ # 并行子 agent 在 .code-flow/worktrees/... 内编辑时路由到该 worktree 的规格与状态
114
+ project_root = effective_project_root(session_root, abs_path)
111
115
  config = load_config(project_root)
112
116
  if not config:
113
117
  return
@@ -117,9 +121,6 @@ def main() -> None:
117
121
  if not quality_loop["post_check"]:
118
122
  return
119
123
 
120
- abs_path = file_path
121
- if not os.path.isabs(abs_path):
122
- abs_path = os.path.join(project_root, file_path)
123
124
  rel_path = normalize_path(os.path.relpath(abs_path, project_root))
124
125
  sid = resolve_session_id(data)
125
126
  cf_log.append_event(project_root, "edit", {"file": rel_path, "tool": tool_name}, sid)
@@ -11,7 +11,16 @@ from pathlib import Path
11
11
  import sys
12
12
 
13
13
  import cf_log
14
- from cf_core import _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id, timing_log
14
+ from cf_core import (
15
+ _log,
16
+ effective_project_root,
17
+ ensure_utf8_io,
18
+ load_config,
19
+ normalize_path,
20
+ resolve_enforcement,
21
+ resolve_session_id,
22
+ timing_log,
23
+ )
15
24
  from cf_session_state import load_session_state, save_session_state
16
25
  from cf_spec_context import injection_version, load_active_task, load_context
17
26
  from cf_spec_resolver import resolve_candidate_headers
@@ -49,11 +58,13 @@ def main() -> None:
49
58
  file_path = (data.get("tool_input") or {}).get("file_path", "")
50
59
  if tool_name not in {"Edit", "Write", "MultiEdit"} or not isinstance(file_path, str) or not file_path:
51
60
  return
52
- root = os.getcwd()
61
+ session_root = os.getcwd()
62
+ absolute = file_path if os.path.isabs(file_path) else os.path.join(session_root, file_path)
63
+ # 并行子 agent 在 .code-flow/worktrees/... 内编辑时路由到该 worktree 的上下文
64
+ root = effective_project_root(session_root, absolute)
53
65
  sid = resolve_session_id(data)
54
66
  config = load_config(root)
55
67
  enforcement = resolve_enforcement(config) if config else "required"
56
- absolute = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
57
68
  relative = normalize_path(os.path.relpath(absolute, root))
58
69
  try:
59
70
  expanded = _active_expansion(root, relative)
@@ -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
 
@@ -1314,8 +1315,15 @@ def apply_artifact_ref(
1314
1315
 
1315
1316
  def _json_payload(stream: IO[str]) -> Mapping[str, object]:
1316
1317
  try:
1317
- loaded = json.load(stream)
1318
- except (json.JSONDecodeError, UnicodeError) as exc:
1318
+ raw = stream.read()
1319
+ except (OSError, UnicodeError) as exc:
1320
+ raise ContextError("invalid_json", "stdin", str(exc)) from exc
1321
+ if not raw.strip():
1322
+ # 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
1323
+ return {}
1324
+ try:
1325
+ loaded = json.loads(raw)
1326
+ except json.JSONDecodeError as exc:
1319
1327
  raise ContextError("invalid_json", "stdin", str(exc)) from exc
1320
1328
  return _mapping(loaded, "stdin", "")
1321
1329
 
@@ -1508,12 +1516,11 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
1508
1516
  except WorkflowError as exc:
1509
1517
  raise _reraise(exc) from exc
1510
1518
  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
- )
1519
+ abandon = payload.get("abandon") is True or args.abandon
1520
+ resync = payload.get("resync") is True or args.resync
1521
+ if abandon and resync:
1522
+ raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
1523
+ result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
1517
1524
  return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1518
1525
  return {"ok": True, "active": _active_data(active)}
1519
1526
 
@@ -1589,7 +1596,7 @@ def _status_text(data: dict[str, object]) -> str:
1589
1596
  match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
1590
1597
  lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
1591
1598
  if not marker["hash_match"]:
1592
- lines.append(" 下一步: 运行 cf-spec doctor(resync 可自动重同步)")
1599
+ lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
1593
1600
  else:
1594
1601
  lines.append("- 无 active TASK(path/catalog 路由模式)")
1595
1602
  gate = data["gate"]
@@ -1643,6 +1650,9 @@ def _parser() -> argparse.ArgumentParser:
1643
1650
  command.add_argument("--task", required=True)
1644
1651
  command.add_argument("--context-sha256", required=True)
1645
1652
  command.add_argument("--json", action="store_true")
1653
+ if action == "doctor":
1654
+ command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
1655
+ command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
1646
1656
  start = commands.add_parser("start")
1647
1657
  start.add_argument("--task-dir", required=True)
1648
1658
  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] = []
@@ -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)}))
@@ -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:
@@ -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