@michaelxwb/code-flow 0.6.2 → 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 (54) 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/start.md +18 -17
  7. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  8. package/src/adapters/claude/commands/cf-validate.md +12 -13
  9. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  10. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  11. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  12. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  13. package/src/adapters/codex/skills/cf-task-start/SKILL.md +18 -17
  14. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  15. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  16. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  17. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  18. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  19. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  20. package/src/adapters/costrict/commands/cf-task/start.md +18 -17
  21. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  22. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  23. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  24. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  25. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  26. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  27. package/src/adapters/opencode/commands/cf-task/start.md +18 -17
  28. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  29. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  30. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  31. package/src/cli.js +177 -63
  32. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  33. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +85 -36
  34. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  35. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  36. package/src/core/code-flow/scripts/cf_core.py +6 -0
  37. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  38. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +8 -4
  39. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  40. package/src/core/code-flow/scripts/cf_spec_context.py +178 -54
  41. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  42. package/src/core/code-flow/scripts/cf_spec_router.py +2 -0
  43. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  44. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  45. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  46. package/src/core/code-flow/scripts/cf_stop_hook.py +98 -59
  47. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  48. package/src/core/code-flow/scripts/cf_task_runtime.py +62 -27
  49. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  50. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  51. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  52. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  53. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  54. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Shared execution substrate: deadline-aware argv subprocess runner.
3
+
4
+ All check executors (acceptance scenarios, Spec verifiers, session
5
+ validators) funnel through here so one entry deadline constrains the whole
6
+ chain, commands never pass through a shell, and unrun work is reported as
7
+ `incomplete` instead of silently skipped or falsely passed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shlex
13
+ import json
14
+ import subprocess
15
+ import time
16
+ from contextlib import contextmanager
17
+ from contextvars import ContextVar
18
+ from pathlib import Path
19
+ from typing import Iterator, Mapping, Optional, Sequence
20
+
21
+ _EXECUTIONS: ContextVar[Optional[dict[str, dict[str, object]]]] = ContextVar("cf_executions", default=None)
22
+
23
+
24
+ @contextmanager
25
+ def execution_session() -> Iterator[None]:
26
+ """Share results only inside one validation operation, never across turns."""
27
+ if _EXECUTIONS.get() is not None:
28
+ yield
29
+ return
30
+ token = _EXECUTIONS.set({})
31
+ try:
32
+ yield
33
+ finally:
34
+ _EXECUTIONS.reset(token)
35
+
36
+
37
+ def invalidate_executions() -> None:
38
+ cache = _EXECUTIONS.get()
39
+ if cache is not None:
40
+ cache.clear()
41
+
42
+
43
+ def execution_key(argv: Sequence[str], cwd: str, timeout: float) -> str:
44
+ """Identity of a command under the current process environment."""
45
+ return json.dumps([list(argv), str(Path(cwd).resolve()), float(timeout)], ensure_ascii=False)
46
+
47
+
48
+ def remaining_seconds(deadline: Optional[float]) -> Optional[float]:
49
+ """Seconds left until the absolute monotonic deadline (None = unbounded)."""
50
+ if deadline is None:
51
+ return None
52
+ return deadline - time.monotonic()
53
+
54
+
55
+ def build_argv(template: str, files: Sequence[str]) -> list[str]:
56
+ """Expand a validator command template into argv without a shell.
57
+
58
+ `{files}` must be a standalone token and expands to one argv entry per
59
+ file (spaces/metachars safe). Embedded usage is a config error — refusing
60
+ is safer than reintroducing shell splitting.
61
+ """
62
+ tokens = shlex.split(template or "")
63
+ if not tokens:
64
+ return []
65
+ argv: list[str] = []
66
+ for token in tokens:
67
+ if token == "{files}":
68
+ argv.extend(files)
69
+ elif "{files}" in token:
70
+ raise ValueError(f"validator command must keep {{files}} as a standalone token: {template!r}")
71
+ else:
72
+ argv.append(token)
73
+ return argv
74
+
75
+
76
+ def run_command(
77
+ argv: Sequence[str],
78
+ cwd: str,
79
+ timeout: float,
80
+ deadline: Optional[float] = None,
81
+ ) -> dict[str, object]:
82
+ """Run argv synchronously under timeout+deadline. Never uses a shell."""
83
+ key = execution_key(argv, cwd, timeout)
84
+ cache = _EXECUTIONS.get()
85
+ if cache is not None and key in cache:
86
+ return dict(cache[key])
87
+ result = _execute(argv, cwd, timeout, deadline)
88
+ if cache is not None and result["status"] == "ok":
89
+ cache[key] = result
90
+ return result
91
+
92
+
93
+ def _execute(argv: Sequence[str], cwd: str, timeout: float, deadline: Optional[float]) -> dict[str, object]:
94
+ remaining = remaining_seconds(deadline)
95
+ if remaining is not None and remaining <= 0:
96
+ return {"status": "deadline_exceeded", "returncode": None, "stdout": "", "stderr": ""}
97
+ effective = min(timeout, remaining) if remaining is not None else timeout
98
+ try:
99
+ proc = subprocess.run(
100
+ list(argv), cwd=cwd, capture_output=True, text=True, timeout=max(effective, 0.01)
101
+ )
102
+ except subprocess.TimeoutExpired as exc:
103
+ out = exc.stdout
104
+ return {
105
+ "status": "timeout",
106
+ "returncode": None,
107
+ "stdout": (out.decode() if isinstance(out, bytes) else (out or ""))[-4000:],
108
+ "stderr": "",
109
+ }
110
+ except OSError as exc:
111
+ return {"status": "spawn_error", "returncode": None, "stdout": "", "stderr": str(exc)}
112
+ return {"status": "ok", "returncode": proc.returncode, "stdout": proc.stdout or "", "stderr": proc.stderr or ""}
@@ -13,7 +13,7 @@ import sys
13
13
  import cf_log
14
14
  from cf_core import _log, ensure_utf8_io, load_config, normalize_path, resolve_enforcement, resolve_session_id, timing_log
15
15
  from cf_session_state import load_session_state, save_session_state
16
- from cf_spec_context import load_active_task, load_context
16
+ from cf_spec_context import injection_version, load_active_task, load_context
17
17
  from cf_spec_resolver import resolve_candidate_headers
18
18
  from cf_spec_router import RouterError, route_prompt
19
19
 
@@ -30,6 +30,7 @@ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
30
30
  return tuple(
31
31
  item.spec_id for item in resolve_candidate_headers(root, "code", (relative,))
32
32
  if item.spec_id not in bound and item.enforcement == "required"
33
+ and getattr(item, "scope", "path") != "unmatched"
33
34
  )
34
35
 
35
36
 
@@ -70,12 +71,15 @@ def main() -> None:
70
71
  text = result.text
71
72
  if result.mode == "task" and result.context_sha256:
72
73
  # The projection is already in context from the prompt route unless
73
- # the bound rules changed; skip per-edit re-injection.
74
+ # the session/task/contract version changed; skip per-edit
75
+ # re-injection. Same version key as the prompt hook so a TASK
76
+ # switch re-injects here too.
74
77
  state = load_session_state(root)
75
- if state.get("injected_sha256") == result.context_sha256:
78
+ version = injection_version(root, sid, result.context_sha256)
79
+ if state.get("injected_version") == version:
76
80
  text = ""
77
81
  else:
78
- state["injected_sha256"] = result.context_sha256
82
+ state["injected_version"] = version
79
83
  save_session_state(root, state)
80
84
  if text:
81
85
  sys.stdout.write(json.dumps(_inject(text), ensure_ascii=False))
@@ -11,6 +11,7 @@ from cf_core import (
11
11
  build_spec_catalog,
12
12
  estimate_tokens,
13
13
  load_config,
14
+ project_instruction_file,
14
15
  non_injectable_specs,
15
16
  parse_spec_frontmatter,
16
17
  )
@@ -120,19 +121,20 @@ def find_missing_paths(text: str, project_root: str, base_dir: str = "") -> list
120
121
  return sorted(missing)
121
122
 
122
123
 
123
- def build_report(project_root: str) -> dict:
124
+ def build_report(project_root: str, platform: str = "") -> dict:
124
125
  """规范质量审计数据(cf-scan 引擎,亦供 cf-stats --audit 复用)。
125
126
 
126
127
  files 含 issues / template 标记,review 为待复审清单;展示层负责过滤与渲染。"""
127
128
  files = []
128
129
  total_tokens = 0
129
130
 
130
- claude_path = os.path.join(project_root, "CLAUDE.md")
131
+ instruction_file = project_instruction_file(project_root, platform)
132
+ claude_path = os.path.join(project_root, instruction_file)
131
133
  if os.path.exists(claude_path):
132
134
  content = read_text(claude_path)
133
135
  tokens = estimate_tokens(content)
134
136
  total_tokens += tokens
135
- files.append({"path": "CLAUDE.md", "tokens": tokens, "issues": []})
137
+ files.append({"path": instruction_file, "tokens": tokens, "issues": []})
136
138
 
137
139
  specs_root = os.path.join(project_root, ".code-flow", "specs")
138
140
  specs = []
@@ -142,14 +144,14 @@ def build_report(project_root: str) -> dict:
142
144
  if not name.endswith(".md"):
143
145
  continue
144
146
  full_path = os.path.join(root, name)
147
+ rel = os.path.relpath(full_path, specs_root)
148
+ if rel.replace(os.sep, "/").startswith("_session/"):
149
+ continue
145
150
  content = read_text(full_path)
146
151
  if not content:
147
152
  continue
148
153
  tokens = estimate_tokens(content)
149
154
  total_tokens += tokens
150
- rel = os.path.relpath(full_path, specs_root)
151
- if rel.replace(os.sep, "/").startswith("_session/"):
152
- continue # 会话级临时约束(FEAT-08)不参与审计与预算
153
155
  rel_path = os.path.join("specs", rel).replace(os.sep, "/")
154
156
  spec_entry = {
155
157
  "path": rel_path,
@@ -159,6 +159,10 @@ class ActiveBaseline:
159
159
  head: Optional[str]
160
160
  captured_at: str
161
161
  preexisting_changes: Mapping[str, PathSnapshot]
162
+ # head is frozen at start and never rewritten: task scope is the union of
163
+ # everything committed since head plus all working-tree changes.
164
+ # last_seen_head is observation-only (diagnostics), never scope input.
165
+ last_seen_head: Optional[str] = None
162
166
 
163
167
 
164
168
  @dataclass(frozen=True)
@@ -285,6 +289,7 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
285
289
  "context_sha256": active.context_sha256,
286
290
  "baseline": {
287
291
  "head": active.baseline.head,
292
+ "last_seen_head": active.baseline.last_seen_head,
288
293
  "captured_at": active.baseline.captured_at,
289
294
  "preexisting_changes": changes,
290
295
  },
@@ -319,6 +324,7 @@ def _active_from_data(value: object, path: str) -> ActiveTask:
319
324
  head,
320
325
  _string(baseline_data.get("captured_at"), "captured_at", path),
321
326
  {key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
327
+ baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
322
328
  )
323
329
  return ActiveTask(
324
330
  1,
@@ -351,6 +357,58 @@ def load_active_task(root: str) -> ActiveTask:
351
357
  raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
352
358
 
353
359
 
360
+ def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
361
+ """Digest of the active TASK's acceptance contract (Refs + Contract + Evidence).
362
+
363
+ Two TASKs may share one Context hash while owning different scenarios; the
364
+ digest separates their injection versions so switching TASKs re-injects.
365
+ Returns "" when the TASK section cannot be located (fail-open to the
366
+ legacy context-hash-only key rather than crashing the hook hot path).
367
+ """
368
+ try:
369
+ files = sorted((Path(root) / task_dir).glob("*.md"))
370
+ except OSError:
371
+ return ""
372
+ for path in files:
373
+ if path.name.endswith((".prd.md", ".design.md")):
374
+ continue
375
+ try:
376
+ text = path.read_text(encoding="utf-8")
377
+ except (OSError, UnicodeError):
378
+ continue
379
+ start = text.find(f"## {task_id}:")
380
+ if start == -1:
381
+ continue
382
+ end = text.find("\n## TASK-", start)
383
+ section = text[start:] if end == -1 else text[start:end]
384
+ chunks: list[str] = []
385
+ for heading in ("Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
386
+ index = section.find(heading)
387
+ if index == -1:
388
+ chunks.append(f"{heading}:missing")
389
+ continue
390
+ end = section.find("\n### ", index)
391
+ chunks.append(section[index:] if end == -1 else section[index:end])
392
+ return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
393
+ return ""
394
+
395
+
396
+ def injection_version(root: str, session_id: str, context_sha: str) -> str:
397
+ """Version key for hook injection dedup.
398
+
399
+ Covers session + task_dir + task_id + context hash + TASK contract digest,
400
+ so a TASK switch (or an in-place contract edit) always re-injects while a
401
+ repeated prompt on the same contract stays suppressed.
402
+ """
403
+ try:
404
+ active = load_active_task(root)
405
+ except (ContextError, OSError, ValueError):
406
+ return f"none:{session_id}:{context_sha}"
407
+ digest = task_contract_digest(root, active.task_dir, active.task_id)
408
+ raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
409
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
410
+
411
+
354
412
  def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
355
413
  """Re-sync the active marker's context hash after toolchain-initiated
356
414
  context edits (bind/decision/refresh) so they never surface as
@@ -433,6 +491,42 @@ def _release_active_lock(lock: Path) -> None:
433
491
  return
434
492
 
435
493
 
494
+ def _committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
495
+ """Paths committed between the frozen baseline head and current HEAD.
496
+
497
+ Returns {} when there is no baseline (pre-commit repo) or the range is
498
+ unresolvable (rewritten/pruned history) — working-tree changes still
499
+ flow through _business_changes, so scope never goes fully blind.
500
+ """
501
+ if not base_head:
502
+ return {}
503
+ try:
504
+ output = _run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
505
+ except ContextError:
506
+ return {}
507
+ names: dict[str, str] = {}
508
+ tokens = output.split("\0")
509
+ index = 0
510
+ while index < len(tokens) and tokens[index]:
511
+ status, index = tokens[index], index + 1
512
+ code = status[:1]
513
+ if code in ("R", "C"):
514
+ if index + 1 >= len(tokens):
515
+ break
516
+ new_path, old_path = tokens[index], tokens[index + 1]
517
+ index += 2
518
+ if new_path:
519
+ names[new_path] = "renamed"
520
+ if old_path:
521
+ names[old_path] = "deleted"
522
+ continue
523
+ if index >= len(tokens) or not tokens[index]:
524
+ break
525
+ names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
526
+ index += 1
527
+ return names
528
+
529
+
436
530
  def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
437
531
  return {
438
532
  path: status
@@ -462,7 +556,8 @@ def _new_active(
462
556
  snapshots = {
463
557
  path: PathSnapshot(status, "") for path, status in changes.items()
464
558
  }
465
- baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
559
+ head = _git_head(root)
560
+ baseline = ActiveBaseline(head, _now(), snapshots, head)
466
561
  return ActiveTask(
467
562
  1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
468
563
  )
@@ -498,9 +593,10 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
498
593
  raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
499
594
  current_head = _git_head(root)
500
595
  if active.baseline.head != current_head:
501
- # Mid-task commits are a normal workflow; re-baseline instead of
502
- # bricking pause/resume/complete on any unrelated commit.
503
- active = replace(active, baseline=replace(active.baseline, head=current_head))
596
+ # Mid-task commits are a normal workflow. The baseline head stays
597
+ # frozen so committed work never drops out of scope; only the
598
+ # observation field advances for diagnostics.
599
+ active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
504
600
  updated = replace(active, status=target)
505
601
  save_active_task(root, updated)
506
602
  return updated
@@ -522,7 +618,12 @@ def resume_active_task(root: str) -> ActiveTask:
522
618
 
523
619
  def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
524
620
  changes = _business_changes(root, active.excluded_paths)
525
- return tuple(sorted(set(active.owned_paths).union(changes)))
621
+ committed = {
622
+ path: status
623
+ for path, status in _committed_since(root, active.baseline.head).items()
624
+ if not _is_excluded(path, active.excluded_paths)
625
+ }
626
+ return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
526
627
 
527
628
 
528
629
  def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
@@ -1337,23 +1438,75 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
1337
1438
 
1338
1439
 
1339
1440
  def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1441
+ from cf_workflow_service import WorkflowError, locate_task_file
1442
+ from cf_workflow_transaction import recover_transition
1443
+
1444
+ recover_transition(args.root)
1445
+ if args.active_action in ("pause", "resume", "block", "complete"):
1446
+ marker, _ = _active_paths(args.root)
1447
+ if marker.exists():
1448
+ current = load_active_task(args.root)
1449
+ if (current.task_id != args.task or
1450
+ (Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
1451
+ raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
1452
+
1453
+ def _directory() -> str:
1454
+ return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
1455
+
1456
+ def _reraise(exc: WorkflowError) -> ContextError:
1457
+ return ContextError(exc.code, "workflow", str(exc), args.task_dir)
1458
+
1459
+ task_file = locate_task_file(_directory(), args.task)
1340
1460
  if args.active_action == "start":
1341
1461
  owned = tuple(
1342
1462
  _string(item, "owned_paths[]", "")
1343
1463
  for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1344
1464
  )
1345
- active = start_active_task(
1346
- args.root, args.task_dir, args.task, args.context_sha256, owned
1347
- )
1348
- return {"ok": True, "active": _active_data(active)}
1465
+ if task_file is None:
1466
+ active = start_active_task(
1467
+ args.root, args.task_dir, args.task, args.context_sha256, owned
1468
+ )
1469
+ return {"ok": True, "active": _active_data(active)}
1470
+ from cf_workflow_service import start_task
1471
+
1472
+ try:
1473
+ return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
1474
+ except WorkflowError as exc:
1475
+ raise _reraise(exc) from exc
1349
1476
  if args.active_action == "pause":
1350
1477
  active = pause_active_task(args.root)
1351
1478
  elif args.active_action == "resume":
1352
- active = resume_active_task(args.root)
1479
+ if task_file is None:
1480
+ active = resume_active_task(args.root)
1481
+ else:
1482
+ from cf_workflow_service import resume_task
1483
+
1484
+ try:
1485
+ return resume_task(args.root, _directory(), str(task_file), args.task)
1486
+ except WorkflowError as exc:
1487
+ raise _reraise(exc) from exc
1353
1488
  elif args.active_action == "block":
1354
- active = block_active_task(args.root)
1489
+ if task_file is None:
1490
+ active = block_active_task(args.root)
1491
+ else:
1492
+ from cf_workflow_service import block_task
1493
+
1494
+ reason = payload.get("reason", "")
1495
+ try:
1496
+ return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
1497
+ except WorkflowError as exc:
1498
+ raise _reraise(exc) from exc
1355
1499
  elif args.active_action == "complete":
1356
- active = complete_active_task(args.root, payload.get("gate_passed") is True)
1500
+ gate = payload.get("gate_passed") is True
1501
+ if task_file is None:
1502
+ active = complete_active_task(args.root, gate)
1503
+ else:
1504
+ from cf_workflow_service import complete_task
1505
+
1506
+ try:
1507
+ return complete_task(args.root, _directory(), str(task_file), args.task, gate)
1508
+ except WorkflowError as exc:
1509
+ raise _reraise(exc) from exc
1357
1510
  else:
1358
1511
  result = doctor_active_task(
1359
1512
  args.root,
@@ -1366,53 +1519,24 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
1366
1519
 
1367
1520
 
1368
1521
  def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1369
- """Run refresh, activation and TASK projection in one guarded process."""
1370
- context_path = Path(args.task_dir) / "spec-context.yml"
1371
- refreshed = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
1372
- save_context(str(context_path), refreshed.context)
1373
- _resync_after_save(args, context_path)
1374
- current_hash = context_sha256(refreshed.context)
1522
+ """Run refresh, activation and TASK projection in one guarded process.
1523
+
1524
+ Delegates to the unified workflow service: hard preconditions (status,
1525
+ NOTES, depends, marker) are enforced before any state changes, and the
1526
+ Markdown view flips to in-progress in the same call.
1527
+ """
1528
+ from cf_workflow_service import WorkflowError, start_task
1529
+
1375
1530
  owned = tuple(
1376
1531
  _string(item, "owned_paths[]", "")
1377
1532
  for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1378
1533
  )
1379
- task_file = Path(args.task_file).resolve()
1380
- task_dir = Path(args.task_dir).resolve()
1381
- if task_file.parent != task_dir or not task_file.is_file():
1382
- raise ContextError("invalid_task_file", "task_file", str(task_file), args.task_dir)
1383
- manifest_file = task_dir / ".acceptance-manifest.json"
1384
- if "## Acceptance Coverage" in task_file.read_text(encoding="utf-8") and not manifest_file.is_file():
1385
- raise ContextError(
1386
- "acceptance_manifest_missing",
1387
- "acceptance_manifest",
1388
- "run plan verification and lock the manifest",
1389
- str(manifest_file),
1534
+ try:
1535
+ return start_task(
1536
+ args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
1390
1537
  )
1391
- if manifest_file.is_file():
1392
- from cf_acceptance_manifest import validate_manifest
1393
-
1394
- valid, reason = validate_manifest(str(task_file), str(manifest_file))
1395
- if not valid:
1396
- raise ContextError(reason, "acceptance_manifest", "run plan verification before coding", str(manifest_file))
1397
- from cf_spec_session import project_task_session
1398
-
1399
- projection = project_task_session(refreshed.context, str(task_file), args.task)
1400
- if projection.truncated:
1401
- raise ContextError("task_projection_truncated", "task_file", "split the TASK before coding")
1402
- output = Path(args.session_output) if args.session_output else (
1403
- Path(args.root) / ".code-flow/specs/_session" / f"task-{task_file.stem}.md"
1404
- )
1405
- output.parent.mkdir(parents=True, exist_ok=True)
1406
- _atomic_text(output, projection.text)
1407
- active = start_active_task(args.root, args.task_dir, args.task, current_hash, owned)
1408
- return {
1409
- "ok": True,
1410
- "context_sha256": current_hash,
1411
- "refresh_changes": [item.__dict__ for item in refreshed.changes],
1412
- "active": _active_data(active),
1413
- "session_output": str(output),
1414
- "acceptance_manifest": str(manifest_file) if manifest_file.is_file() else None,
1415
- }
1538
+ except WorkflowError as exc:
1539
+ raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
1416
1540
 
1417
1541
 
1418
1542
  def _status_command(args: argparse.Namespace) -> dict[str, object]:
@@ -18,7 +18,7 @@ from cf_spec_metadata import (
18
18
  )
19
19
 
20
20
 
21
- SCOPE_PRIORITY = {"global": 1, "path": 2, "task": 3}
21
+ SCOPE_PRIORITY = {"unmatched": 0, "global": 1, "path": 2, "task": 3}
22
22
  _config_cache: dict[str, tuple[int, int, Mapping[str, object]]] = {}
23
23
  _metadata_cache: dict[str, tuple[int, int, SpecMetadata]] = {}
24
24
 
@@ -174,7 +174,12 @@ def _candidate_scope(relative: str, patterns: Sequence[str], paths: Sequence[str
174
174
  if relative.startswith("_session/"):
175
175
  return "task", ()
176
176
  matched = tuple(path for path in paths if any(fnmatch.fnmatch(path, pattern) for pattern in patterns))
177
- return ("path", matched) if matched else ("global", ())
177
+ if matched:
178
+ return "path", matched
179
+ # A domain without patterns is truly global. A domain WITH patterns that
180
+ # match nothing is irrelevant to these paths ("unmatched") — discovery
181
+ # keeps it, but injection and scope expansion must not consume it.
182
+ return ("global", ()) if not patterns else ("unmatched", ())
178
183
 
179
184
 
180
185
  def _candidate_for(
@@ -162,6 +162,8 @@ def _read_candidates(root: str, paths: Sequence[str], compress: bool = True) ->
162
162
  raise RouterError("invalid_spec_metadata", str(exc)) from exc
163
163
  specs = []
164
164
  for candidate in candidates:
165
+ if candidate.scope == "unmatched":
166
+ continue
165
167
  target = Path(root) / ".code-flow/specs" / candidate.path
166
168
  try:
167
169
  raw = target.read_text(encoding="utf-8")
@@ -36,7 +36,8 @@ def _task_section(text: str, task_id: str) -> str:
36
36
 
37
37
 
38
38
  def _refs(section: str) -> tuple[str, ...]:
39
- match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:\s*(.+)$", section)
39
+ # [ \t]* 而非 \s*:空 Spec-Refs 不得跨行吞掉下一字段(Acceptance-Refs 等)
40
+ match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:[ \t]*(.*)$", section)
40
41
  if match is None:
41
42
  raise ValueError("spec_refs_missing")
42
43
  return tuple(item.strip() for item in match.group(1).split(",") if item.strip())
@@ -83,7 +84,7 @@ def project_task_session(
83
84
  if len(header + "".join(lines) + candidate + footer) > max_chars:
84
85
  break
85
86
  lines.append(candidate)
86
- truncated = len(lines) < len(refs)
87
+ truncated = len(lines) < len(refs) or len(header + "".join(lines) + footer) > max_chars
87
88
  if truncated:
88
89
  footer = "\n> 投影超过预算;完整 Context 保留在需求目录,请拆分 TASK。\n" + footer
89
90
  text = (header + "".join(lines) + footer)[:max_chars]