@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
@@ -14,12 +14,12 @@ from pathlib import Path
14
14
  import subprocess
15
15
  import sys
16
16
  import tempfile
17
+ import time
17
18
  from typing import IO, Mapping, Optional, Sequence
18
19
 
19
- import yaml
20
-
21
20
  from cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
22
21
  from cf_spec_resolver import SpecCandidate, resolve_candidates
22
+ from cf_core import phase_timing
23
23
 
24
24
 
25
25
  CONTEXT_VERSION = 1
@@ -28,12 +28,17 @@ STAGE_STATUSES = frozenset(
28
28
  )
29
29
  DECISION_KINDS = frozenset(("not_applicable", "waived", "manual_verification"))
30
30
  _AGENT_IDENTITIES = frozenset(("agent", "assistant", "codex", "claude", "opencode", "costrict"))
31
+ _FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
32
+ _PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
33
+ _PERSISTENT_HASH_PATHS: set[str] = set()
34
+ _MAX_HASH_CACHE_ENTRIES = 4096
31
35
  ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
32
36
  DEFAULT_ACTIVE_EXCLUDES = (
33
37
  ".code-flow/tasks/*",
34
38
  ".code-flow/specs/_session/*",
35
39
  ".code-flow/migrations/*",
36
40
  ".code-flow/.*state*",
41
+ ".code-flow/.*cache*",
37
42
  ".code-flow/.session-log.jsonl",
38
43
  ".code-flow/.active-task.json",
39
44
  ".code-flow/.active-task.lock",
@@ -154,6 +159,10 @@ class ActiveBaseline:
154
159
  head: Optional[str]
155
160
  captured_at: str
156
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
157
166
 
158
167
 
159
168
  @dataclass(frozen=True)
@@ -208,7 +217,7 @@ def _active_paths(root: str) -> tuple[Path, Path]:
208
217
  def _run_git(root: str, arguments: Sequence[str]) -> str:
209
218
  try:
210
219
  result = subprocess.run(
211
- ("git", *arguments), cwd=root, text=True, capture_output=True, check=False
220
+ ("git", *arguments), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
212
221
  )
213
222
  except OSError as exc:
214
223
  raise ContextError("git_unavailable", "git", str(exc), root) from exc
@@ -267,16 +276,6 @@ def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
267
276
  return any(fnmatchcase(path, pattern) for pattern in patterns)
268
277
 
269
278
 
270
- def _path_hash(root: str, path: str) -> str:
271
- target = Path(root) / path
272
- digest = hashlib.sha256()
273
- if target.is_file():
274
- with target.open("rb") as handle:
275
- for chunk in iter(lambda: handle.read(65536), b""):
276
- digest.update(chunk)
277
- return digest.hexdigest()
278
-
279
-
280
279
  def _active_data(active: ActiveTask) -> dict[str, object]:
281
280
  changes = {
282
281
  path: {"status": item.status, "content_sha256": item.content_sha256}
@@ -290,6 +289,7 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
290
289
  "context_sha256": active.context_sha256,
291
290
  "baseline": {
292
291
  "head": active.baseline.head,
292
+ "last_seen_head": active.baseline.last_seen_head,
293
293
  "captured_at": active.baseline.captured_at,
294
294
  "preexisting_changes": changes,
295
295
  },
@@ -300,9 +300,10 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
300
300
 
301
301
  def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
302
302
  data = _mapping(value, "preexisting_changes[]", path)
303
+ content_hash = data.get("content_sha256")
303
304
  return PathSnapshot(
304
305
  _string(data.get("status"), "status", path),
305
- _string(data.get("content_sha256"), "content_sha256", path),
306
+ content_hash if isinstance(content_hash, str) else "",
306
307
  )
307
308
 
308
309
 
@@ -323,6 +324,7 @@ def _active_from_data(value: object, path: str) -> ActiveTask:
323
324
  head,
324
325
  _string(baseline_data.get("captured_at"), "captured_at", path),
325
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,
326
328
  )
327
329
  return ActiveTask(
328
330
  1,
@@ -355,6 +357,58 @@ def load_active_task(root: str) -> ActiveTask:
355
357
  raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
356
358
 
357
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
+
358
412
  def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
359
413
  """Re-sync the active marker's context hash after toolchain-initiated
360
414
  context edits (bind/decision/refresh) so they never surface as
@@ -437,6 +491,42 @@ def _release_active_lock(lock: Path) -> None:
437
491
  return
438
492
 
439
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
+
440
530
  def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
441
531
  return {
442
532
  path: status
@@ -460,10 +550,14 @@ def _new_active(
460
550
  raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
461
551
  if unowned:
462
552
  raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
553
+ # content_sha256 was a per-file hash but is never consumed by any gate (only
554
+ # baseline.head is compared); keep the schema field empty so start never hashes
555
+ # every uncommitted file.
463
556
  snapshots = {
464
- path: PathSnapshot(status, _path_hash(root, path)) for path, status in changes.items()
557
+ path: PathSnapshot(status, "") for path, status in changes.items()
465
558
  }
466
- baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
559
+ head = _git_head(root)
560
+ baseline = ActiveBaseline(head, _now(), snapshots, head)
467
561
  return ActiveTask(
468
562
  1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
469
563
  )
@@ -499,9 +593,10 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
499
593
  raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
500
594
  current_head = _git_head(root)
501
595
  if active.baseline.head != current_head:
502
- # Mid-task commits are a normal workflow; re-baseline instead of
503
- # bricking pause/resume/complete on any unrelated commit.
504
- 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))
505
600
  updated = replace(active, status=target)
506
601
  save_active_task(root, updated)
507
602
  return updated
@@ -523,7 +618,12 @@ def resume_active_task(root: str) -> ActiveTask:
523
618
 
524
619
  def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
525
620
  changes = _business_changes(root, active.excluded_paths)
526
- 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)))
527
627
 
528
628
 
529
629
  def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
@@ -822,6 +922,7 @@ def context_sha256(context: SpecContext) -> str:
822
922
  Computed from the stable identity projection (never volatile runtime state),
823
923
  so evidence timestamps and stage statuses cannot drift the marker.
824
924
  """
925
+ import yaml
825
926
  data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
826
927
  return hashlib.sha256(data).hexdigest()
827
928
 
@@ -834,6 +935,7 @@ def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
834
935
 
835
936
 
836
937
  def load_context(path: str) -> SpecContext:
938
+ import yaml
837
939
  try:
838
940
  loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
839
941
  except (OSError, UnicodeError, yaml.YAMLError) as exc:
@@ -862,6 +964,7 @@ def load_context(path: str) -> SpecContext:
862
964
 
863
965
 
864
966
  def save_context(path: str, context: SpecContext) -> None:
967
+ import yaml
865
968
  target = Path(path)
866
969
  target.parent.mkdir(parents=True, exist_ok=True)
867
970
  text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
@@ -965,7 +1068,57 @@ def _removed_rule(rule: RuleBinding) -> RuleBinding:
965
1068
 
966
1069
  def _file_sha256(path: Path) -> str:
967
1070
  try:
968
- return hashlib.sha256(path.read_bytes()).hexdigest()
1071
+ stat = path.stat()
1072
+ key = str(path)
1073
+ cached = _FILE_HASH_CACHE.get(key)
1074
+ signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
1075
+ if cached is not None and cached[:3] == signature:
1076
+ return cached[3]
1077
+ cache_path = next(
1078
+ (parent / ".artifact-hash-cache.json" for parent in path.parents if parent.name == ".code-flow"),
1079
+ None,
1080
+ )
1081
+ cache_key = str(cache_path) if cache_path is not None else ""
1082
+ if cache_key and cache_key not in _PERSISTENT_HASH_PATHS:
1083
+ try:
1084
+ loaded = json.loads(cache_path.read_text(encoding="utf-8"))
1085
+ if isinstance(loaded, dict):
1086
+ _PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
1087
+ except (OSError, ValueError):
1088
+ pass
1089
+ _PERSISTENT_HASH_PATHS.add(cache_key)
1090
+ persistent = _PERSISTENT_HASH_CACHE.get(key)
1091
+ if (
1092
+ isinstance(persistent, dict)
1093
+ and persistent.get("mtime_ns") == stat.st_mtime_ns
1094
+ and persistent.get("size") == stat.st_size
1095
+ and persistent.get("ino") == stat.st_ino
1096
+ ):
1097
+ digest = persistent.get("sha256")
1098
+ if isinstance(digest, str) and digest:
1099
+ _FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
1100
+ return digest
1101
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
1102
+ _FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
1103
+ if cache_key:
1104
+ _PERSISTENT_HASH_CACHE[key] = {
1105
+ "mtime_ns": stat.st_mtime_ns,
1106
+ "size": stat.st_size,
1107
+ "ino": stat.st_ino,
1108
+ "sha256": digest,
1109
+ }
1110
+ while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
1111
+ _PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
1112
+ try:
1113
+ temporary = cache_path.with_suffix(".json.tmp")
1114
+ temporary.write_text(
1115
+ json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True),
1116
+ encoding="utf-8",
1117
+ )
1118
+ temporary.replace(cache_path)
1119
+ except OSError:
1120
+ pass
1121
+ return digest
969
1122
  except OSError as exc:
970
1123
  raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
971
1124
 
@@ -1057,15 +1210,19 @@ def _refresh_binding(
1057
1210
 
1058
1211
 
1059
1212
  def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
1213
+ started = time.monotonic()
1060
1214
  specs = Path(root) / ".code-flow" / "specs"
1061
1215
  artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
1062
1216
  bindings: list[SpecBinding] = []
1063
1217
  changes: list[DriftChange] = []
1064
1218
  for binding in context.bindings:
1219
+ phase_started = time.monotonic()
1065
1220
  updated, binding_changes = _refresh_binding(binding, specs, artifacts)
1221
+ phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
1066
1222
  bindings.append(updated)
1067
1223
  changes.extend(binding_changes)
1068
1224
  refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
1225
+ phase_timing("context.refresh.total", started)
1069
1226
  if refreshed.bindings == context.bindings:
1070
1227
  return DriftResult(context, tuple(changes))
1071
1228
  return DriftResult(refreshed, tuple(changes))
@@ -1281,23 +1438,75 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
1281
1438
 
1282
1439
 
1283
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)
1284
1460
  if args.active_action == "start":
1285
1461
  owned = tuple(
1286
1462
  _string(item, "owned_paths[]", "")
1287
1463
  for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1288
1464
  )
1289
- active = start_active_task(
1290
- args.root, args.task_dir, args.task, args.context_sha256, owned
1291
- )
1292
- 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
1293
1476
  if args.active_action == "pause":
1294
1477
  active = pause_active_task(args.root)
1295
1478
  elif args.active_action == "resume":
1296
- 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
1297
1488
  elif args.active_action == "block":
1298
- 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
1299
1499
  elif args.active_action == "complete":
1300
- 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
1301
1510
  else:
1302
1511
  result = doctor_active_task(
1303
1512
  args.root,
@@ -1309,6 +1518,27 @@ def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) ->
1309
1518
  return {"ok": True, "active": _active_data(active)}
1310
1519
 
1311
1520
 
1521
+ def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
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
+
1530
+ owned = tuple(
1531
+ _string(item, "owned_paths[]", "")
1532
+ for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1533
+ )
1534
+ try:
1535
+ return start_task(
1536
+ args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
1537
+ )
1538
+ except WorkflowError as exc:
1539
+ raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
1540
+
1541
+
1312
1542
  def _status_command(args: argparse.Namespace) -> dict[str, object]:
1313
1543
  """Human-readable Spec Context status: task, marker health, gate, rules."""
1314
1544
  from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
@@ -1413,6 +1643,13 @@ def _parser() -> argparse.ArgumentParser:
1413
1643
  command.add_argument("--task", required=True)
1414
1644
  command.add_argument("--context-sha256", required=True)
1415
1645
  command.add_argument("--json", action="store_true")
1646
+ start = commands.add_parser("start")
1647
+ start.add_argument("--task-dir", required=True)
1648
+ start.add_argument("--root", required=True)
1649
+ start.add_argument("--task", required=True)
1650
+ start.add_argument("--task-file", required=True)
1651
+ start.add_argument("--session-output", default="")
1652
+ start.add_argument("--json", action="store_true")
1416
1653
  return parser
1417
1654
 
1418
1655
 
@@ -1421,6 +1658,8 @@ def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1421
1658
  return _catalog_command(args)
1422
1659
  if args.command == "active":
1423
1660
  return _active_command(args, _json_payload(stdin))
1661
+ if args.command == "start":
1662
+ return _start_command(args, _json_payload(stdin))
1424
1663
  context_path = Path(args.task_dir) / "spec-context.yml"
1425
1664
  if args.command == "decision":
1426
1665
  return _decision_command(args, _json_payload(stdin))
@@ -10,9 +10,11 @@ import json
10
10
  from pathlib import Path
11
11
  import re
12
12
  import sys
13
+ import time
13
14
  from typing import IO, Mapping, Optional, Sequence
14
15
 
15
16
  from cf_spec_context import Decision, RuleBinding, RuleStageStatus, SpecBinding, SpecContext, load_context
17
+ from cf_core import phase_timing
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -212,13 +214,21 @@ def _parser() -> argparse.ArgumentParser:
212
214
 
213
215
 
214
216
  def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
217
+ started = time.monotonic()
215
218
  try:
216
219
  args = _parser().parse_args(argv)
220
+ phase_started = time.monotonic()
217
221
  context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
222
+ phase_timing("gate.load_context", phase_started)
223
+ phase_started = time.monotonic()
218
224
  result = validate_stage(context, args.stage, task_id=args.task)
225
+ phase_timing("gate.validate_stage", phase_started)
219
226
  if args.stage == "plan" and args.artifact:
227
+ phase_started = time.monotonic()
220
228
  result = _merge_results(result, validate_plan_coverage(context, args.artifact))
229
+ phase_timing("gate.validate_plan", phase_started)
221
230
  stdout.write(json.dumps(result_to_data(result), ensure_ascii=False))
231
+ phase_timing("gate.total", started)
222
232
  return 0 if result.decision == "pass" else 3
223
233
  except Exception as exc:
224
234
  sys.stderr.write(f"cf_spec_gate error: {exc}\n")
@@ -10,7 +10,6 @@ from pathlib import Path
10
10
  import re
11
11
  from typing import Mapping, Optional
12
12
 
13
- import yaml
14
13
 
15
14
 
16
15
  ALLOWED_STAGES = frozenset(("prd", "design", "plan", "code", "review"))
@@ -20,6 +19,8 @@ _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
20
19
  _RULE_ID_RE = re.compile(r"^RULE-[a-z0-9]+(?:-[a-z0-9]+)*-\d{3}$")
21
20
  _EXPLICIT_RULE_RE = re.compile(r"^\[(RULE-[a-z0-9-]+-\d{3})\]\s+(.+)$")
22
21
  _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
22
+ _FRONTMATTER_OPEN_RE = re.compile(br"---[ \t]*\r?\n")
23
+ _FRONTMATTER_CLOSE_RE = re.compile(br"---[ \t]*(?:\r?\n)?")
23
24
  _SECTION_RE = re.compile(r"^##\s+(Rules|Anti-Patterns|Patterns|Examples)\s*$")
24
25
  _H2_RE = re.compile(r"^##\s+")
25
26
  _BULLET_RE = re.compile(r"^\s*-\s+(.+\S|\S)\s*$")
@@ -63,6 +64,13 @@ class SpecRule:
63
64
  line: int
64
65
 
65
66
 
67
+ @dataclass(frozen=True)
68
+ class SpecHeader:
69
+ id: str
70
+ stages: tuple[str, ...]
71
+ enforcement: str
72
+
73
+
66
74
  @dataclass(frozen=True)
67
75
  class SpecMetadata:
68
76
  id: str
@@ -116,6 +124,11 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
116
124
  if not match:
117
125
  raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
118
126
  raw = match.group(1)
127
+ return _parse_frontmatter(raw, path), raw, content[match.end():]
128
+
129
+
130
+ def _parse_frontmatter(raw: str, path: str) -> Mapping[str, object]:
131
+ import yaml
119
132
  try:
120
133
  loaded = yaml.safe_load(raw) or {}
121
134
  except yaml.YAMLError as exc:
@@ -126,7 +139,7 @@ def _split_frontmatter(content: str, path: str) -> tuple[Mapping[str, object], s
126
139
  raise SpecMetadataError(path, "frontmatter", 2, "顶层必须是 mapping")
127
140
  if not all(isinstance(key, str) for key in loaded):
128
141
  raise SpecMetadataError(path, "frontmatter", 2, "所有顶层 key 必须是字符串")
129
- return loaded, raw, content[match.end():]
142
+ return loaded
130
143
 
131
144
 
132
145
  def _required_string(meta: Mapping[str, object], key: str, path: str, raw: str) -> str:
@@ -149,6 +162,46 @@ def _validate_stages(meta: Mapping[str, object], path: str, raw: str) -> tuple[s
149
162
  return stages
150
163
 
151
164
 
165
+ def _spec_header(meta: Mapping[str, object], raw: str, path: str) -> SpecHeader:
166
+ spec_id = _required_string(meta, "id", path, raw)
167
+ if not _ID_RE.fullmatch(spec_id):
168
+ raise SpecMetadataError(path, "id", _field_line(raw, "id"), "必须是 kebab-case")
169
+ stages = _validate_stages(meta, path, raw)
170
+ enforcement = _required_string(meta, "enforcement", path, raw)
171
+ if enforcement not in ALLOWED_ENFORCEMENT:
172
+ line = _field_line(raw, "enforcement")
173
+ raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
174
+ return SpecHeader(spec_id, stages, enforcement)
175
+
176
+
177
+ def _read_frontmatter(path: str) -> str:
178
+ lines: list[bytes] = []
179
+ try:
180
+ with Path(path).open("rb") as source:
181
+ if not _FRONTMATTER_OPEN_RE.fullmatch(source.readline()):
182
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
183
+ for line in source:
184
+ if _FRONTMATTER_CLOSE_RE.fullmatch(line):
185
+ break
186
+ lines.append(line)
187
+ else:
188
+ raise SpecMetadataError(path, "frontmatter", 1, "缺失或未闭合 YAML frontmatter")
189
+ except OSError as exc:
190
+ raise SpecMetadataError(path, "file", 1, f"读取失败: {exc}") from exc
191
+ raw = b"".join(lines)
192
+ try:
193
+ return raw.decode("utf-8")
194
+ except UnicodeDecodeError as exc:
195
+ line = raw[:exc.start].count(b"\n") + 2
196
+ raise SpecMetadataError(path, "encoding", line, f"必须是 UTF-8: {exc}") from exc
197
+
198
+
199
+ def load_spec_header(path: str) -> SpecHeader:
200
+ """Load only routing fields, stopping at the frontmatter delimiter."""
201
+ raw = _read_frontmatter(path)
202
+ return _spec_header(_parse_frontmatter(raw, path), raw, path)
203
+
204
+
152
205
  def _validate_checks(meta: Mapping[str, object], path: str, raw: str) -> tuple[Mapping[str, object], ...]:
153
206
  value = meta.get("checks", [])
154
207
  if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
@@ -265,15 +318,8 @@ def _validate_links(
265
318
 
266
319
  def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optional[bytes] = None) -> SpecMetadata:
267
320
  meta, raw_frontmatter, body = _split_frontmatter(content, path)
268
- spec_id = _required_string(meta, "id", path, raw_frontmatter)
269
- if not _ID_RE.fullmatch(spec_id):
270
- raise SpecMetadataError(path, "id", _field_line(raw_frontmatter, "id"), "必须是 kebab-case")
321
+ header = _spec_header(meta, raw_frontmatter, path)
271
322
  description = _required_string(meta, "description", path, raw_frontmatter)
272
- stages = _validate_stages(meta, path, raw_frontmatter)
273
- enforcement = _required_string(meta, "enforcement", path, raw_frontmatter)
274
- if enforcement not in ALLOWED_ENFORCEMENT:
275
- line = _field_line(raw_frontmatter, "enforcement")
276
- raise SpecMetadataError(path, "enforcement", line, f"必须是 {sorted(ALLOWED_ENFORCEMENT)}")
277
323
  owner = meta.get("owner")
278
324
  if owner is not None and (not isinstance(owner, str) or not owner.strip()):
279
325
  raise SpecMetadataError(path, "owner", _field_line(raw_frontmatter, "owner"), "必须是非空字符串")
@@ -289,7 +335,10 @@ def parse_spec_metadata(content: str, path: str = "<memory>", raw_bytes: Optiona
289
335
  metadata_sha256=_sha256(_canonical_json(meta, path, "frontmatter", 1)),
290
336
  rules_sha256=_sha256(_canonical_json(required_rules, path, "rules", 1)),
291
337
  )
292
- return SpecMetadata(spec_id, description, stages, enforcement, owner, checks, verifiers, rules, hashes, path)
338
+ return SpecMetadata(
339
+ header.id, description, header.stages, header.enforcement,
340
+ owner, checks, verifiers, rules, hashes, path,
341
+ )
293
342
 
294
343
 
295
344
  def load_spec_metadata(path: str) -> SpecMetadata: