@michaelxwb/code-flow 0.6.2 → 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 (74) 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 +5 -5
  6. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  7. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  8. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  9. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  10. package/src/adapters/claude/commands/cf-task/start.md +96 -25
  11. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  12. package/src/adapters/claude/commands/cf-validate.md +15 -13
  13. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  14. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  15. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  16. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  17. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  18. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  19. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
  21. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  22. package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
  23. package/src/adapters/costrict/commands/cf-init.md +1 -0
  24. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  25. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  27. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  29. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  30. package/src/adapters/costrict/commands/cf-task/start.md +96 -25
  31. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  32. package/src/adapters/costrict/commands/cf-validate.md +15 -13
  33. package/src/adapters/opencode/commands/cf-init.md +9 -4
  34. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  35. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  36. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  37. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  38. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  39. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  40. package/src/adapters/opencode/commands/cf-task/start.md +96 -25
  41. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  42. package/src/adapters/opencode/commands/cf-validate.md +15 -13
  43. package/src/adapters/opencode/opencode.json +1 -2
  44. package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
  45. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  46. package/src/cli.js +228 -64
  47. package/src/core/code-flow/config.yml +2 -1
  48. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  49. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
  50. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  51. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  52. package/src/core/code-flow/scripts/cf_core.py +34 -1
  53. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  54. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  55. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
  56. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  57. package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
  58. package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
  59. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  60. package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
  61. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  62. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  63. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  64. package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
  65. package/src/core/code-flow/scripts/cf_task_index.py +148 -0
  66. package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
  67. package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
  68. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  69. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  70. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  71. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  72. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  73. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
  74. package/src/core/code-flow/validation.yml +5 -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 ""}
@@ -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,9 +11,18 @@ 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
- from cf_spec_context import load_active_task, load_context
25
+ from cf_spec_context import injection_version, load_active_task, load_context
17
26
  from cf_spec_resolver import resolve_candidate_headers
18
27
  from cf_spec_router import RouterError, route_prompt
19
28
 
@@ -30,6 +39,7 @@ def _active_expansion(root: str, relative: str) -> tuple[str, ...]:
30
39
  return tuple(
31
40
  item.spec_id for item in resolve_candidate_headers(root, "code", (relative,))
32
41
  if item.spec_id not in bound and item.enforcement == "required"
42
+ and getattr(item, "scope", "path") != "unmatched"
33
43
  )
34
44
 
35
45
 
@@ -48,11 +58,13 @@ def main() -> None:
48
58
  file_path = (data.get("tool_input") or {}).get("file_path", "")
49
59
  if tool_name not in {"Edit", "Write", "MultiEdit"} or not isinstance(file_path, str) or not file_path:
50
60
  return
51
- 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)
52
65
  sid = resolve_session_id(data)
53
66
  config = load_config(root)
54
67
  enforcement = resolve_enforcement(config) if config else "required"
55
- absolute = file_path if os.path.isabs(file_path) else os.path.join(root, file_path)
56
68
  relative = normalize_path(os.path.relpath(absolute, root))
57
69
  try:
58
70
  expanded = _active_expansion(root, relative)
@@ -70,12 +82,15 @@ def main() -> None:
70
82
  text = result.text
71
83
  if result.mode == "task" and result.context_sha256:
72
84
  # The projection is already in context from the prompt route unless
73
- # the bound rules changed; skip per-edit re-injection.
85
+ # the session/task/contract version changed; skip per-edit
86
+ # re-injection. Same version key as the prompt hook so a TASK
87
+ # switch re-injects here too.
74
88
  state = load_session_state(root)
75
- if state.get("injected_sha256") == result.context_sha256:
89
+ version = injection_version(root, sid, result.context_sha256)
90
+ if state.get("injected_version") == version:
76
91
  text = ""
77
92
  else:
78
- state["injected_sha256"] = result.context_sha256
93
+ state["injected_version"] = version
79
94
  save_session_state(root, state)
80
95
  if text:
81
96
  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,
@@ -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
 
@@ -159,6 +160,10 @@ class ActiveBaseline:
159
160
  head: Optional[str]
160
161
  captured_at: str
161
162
  preexisting_changes: Mapping[str, PathSnapshot]
163
+ # head is frozen at start and never rewritten: task scope is the union of
164
+ # everything committed since head plus all working-tree changes.
165
+ # last_seen_head is observation-only (diagnostics), never scope input.
166
+ last_seen_head: Optional[str] = None
162
167
 
163
168
 
164
169
  @dataclass(frozen=True)
@@ -285,6 +290,7 @@ def _active_data(active: ActiveTask) -> dict[str, object]:
285
290
  "context_sha256": active.context_sha256,
286
291
  "baseline": {
287
292
  "head": active.baseline.head,
293
+ "last_seen_head": active.baseline.last_seen_head,
288
294
  "captured_at": active.baseline.captured_at,
289
295
  "preexisting_changes": changes,
290
296
  },
@@ -319,6 +325,7 @@ def _active_from_data(value: object, path: str) -> ActiveTask:
319
325
  head,
320
326
  _string(baseline_data.get("captured_at"), "captured_at", path),
321
327
  {key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
328
+ baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
322
329
  )
323
330
  return ActiveTask(
324
331
  1,
@@ -351,6 +358,58 @@ def load_active_task(root: str) -> ActiveTask:
351
358
  raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
352
359
 
353
360
 
361
+ def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
362
+ """Digest of the active TASK's acceptance contract (Refs + Contract + Evidence).
363
+
364
+ Two TASKs may share one Context hash while owning different scenarios; the
365
+ digest separates their injection versions so switching TASKs re-injects.
366
+ Returns "" when the TASK section cannot be located (fail-open to the
367
+ legacy context-hash-only key rather than crashing the hook hot path).
368
+ """
369
+ try:
370
+ files = sorted((Path(root) / task_dir).glob("*.md"))
371
+ except OSError:
372
+ return ""
373
+ for path in files:
374
+ if path.name.endswith((".prd.md", ".design.md")):
375
+ continue
376
+ try:
377
+ text = path.read_text(encoding="utf-8")
378
+ except (OSError, UnicodeError):
379
+ continue
380
+ start = text.find(f"## {task_id}:")
381
+ if start == -1:
382
+ continue
383
+ end = text.find("\n## TASK-", start)
384
+ section = text[start:] if end == -1 else text[start:end]
385
+ chunks: list[str] = []
386
+ for heading in ("Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
387
+ index = section.find(heading)
388
+ if index == -1:
389
+ chunks.append(f"{heading}:missing")
390
+ continue
391
+ end = section.find("\n### ", index)
392
+ chunks.append(section[index:] if end == -1 else section[index:end])
393
+ return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
394
+ return ""
395
+
396
+
397
+ def injection_version(root: str, session_id: str, context_sha: str) -> str:
398
+ """Version key for hook injection dedup.
399
+
400
+ Covers session + task_dir + task_id + context hash + TASK contract digest,
401
+ so a TASK switch (or an in-place contract edit) always re-injects while a
402
+ repeated prompt on the same contract stays suppressed.
403
+ """
404
+ try:
405
+ active = load_active_task(root)
406
+ except (ContextError, OSError, ValueError):
407
+ return f"none:{session_id}:{context_sha}"
408
+ digest = task_contract_digest(root, active.task_dir, active.task_id)
409
+ raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
410
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
411
+
412
+
354
413
  def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
355
414
  """Re-sync the active marker's context hash after toolchain-initiated
356
415
  context edits (bind/decision/refresh) so they never surface as
@@ -433,6 +492,42 @@ def _release_active_lock(lock: Path) -> None:
433
492
  return
434
493
 
435
494
 
495
+ def _committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
496
+ """Paths committed between the frozen baseline head and current HEAD.
497
+
498
+ Returns {} when there is no baseline (pre-commit repo) or the range is
499
+ unresolvable (rewritten/pruned history) — working-tree changes still
500
+ flow through _business_changes, so scope never goes fully blind.
501
+ """
502
+ if not base_head:
503
+ return {}
504
+ try:
505
+ output = _run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
506
+ except ContextError:
507
+ return {}
508
+ names: dict[str, str] = {}
509
+ tokens = output.split("\0")
510
+ index = 0
511
+ while index < len(tokens) and tokens[index]:
512
+ status, index = tokens[index], index + 1
513
+ code = status[:1]
514
+ if code in ("R", "C"):
515
+ if index + 1 >= len(tokens):
516
+ break
517
+ new_path, old_path = tokens[index], tokens[index + 1]
518
+ index += 2
519
+ if new_path:
520
+ names[new_path] = "renamed"
521
+ if old_path:
522
+ names[old_path] = "deleted"
523
+ continue
524
+ if index >= len(tokens) or not tokens[index]:
525
+ break
526
+ names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
527
+ index += 1
528
+ return names
529
+
530
+
436
531
  def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
437
532
  return {
438
533
  path: status
@@ -462,7 +557,8 @@ def _new_active(
462
557
  snapshots = {
463
558
  path: PathSnapshot(status, "") for path, status in changes.items()
464
559
  }
465
- baseline = ActiveBaseline(_git_head(root), _now(), snapshots)
560
+ head = _git_head(root)
561
+ baseline = ActiveBaseline(head, _now(), snapshots, head)
466
562
  return ActiveTask(
467
563
  1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
468
564
  )
@@ -498,9 +594,10 @@ def _transition_active(root: str, expected: Sequence[str], target: str) -> Activ
498
594
  raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
499
595
  current_head = _git_head(root)
500
596
  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))
597
+ # Mid-task commits are a normal workflow. The baseline head stays
598
+ # frozen so committed work never drops out of scope; only the
599
+ # observation field advances for diagnostics.
600
+ active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
504
601
  updated = replace(active, status=target)
505
602
  save_active_task(root, updated)
506
603
  return updated
@@ -522,7 +619,12 @@ def resume_active_task(root: str) -> ActiveTask:
522
619
 
523
620
  def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
524
621
  changes = _business_changes(root, active.excluded_paths)
525
- return tuple(sorted(set(active.owned_paths).union(changes)))
622
+ committed = {
623
+ path: status
624
+ for path, status in _committed_since(root, active.baseline.head).items()
625
+ if not _is_excluded(path, active.excluded_paths)
626
+ }
627
+ return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
526
628
 
527
629
 
528
630
  def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
@@ -1213,8 +1315,15 @@ def apply_artifact_ref(
1213
1315
 
1214
1316
  def _json_payload(stream: IO[str]) -> Mapping[str, object]:
1215
1317
  try:
1216
- loaded = json.load(stream)
1217
- 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:
1218
1327
  raise ContextError("invalid_json", "stdin", str(exc)) from exc
1219
1328
  return _mapping(loaded, "stdin", "")
1220
1329
 
@@ -1337,82 +1446,104 @@ def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> di
1337
1446
 
1338
1447
 
1339
1448
  def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1449
+ from cf_workflow_service import WorkflowError, locate_task_file
1450
+ from cf_workflow_transaction import recover_transition
1451
+
1452
+ recover_transition(args.root)
1453
+ if args.active_action in ("pause", "resume", "block", "complete"):
1454
+ marker, _ = _active_paths(args.root)
1455
+ if marker.exists():
1456
+ current = load_active_task(args.root)
1457
+ if (current.task_id != args.task or
1458
+ (Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
1459
+ raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
1460
+
1461
+ def _directory() -> str:
1462
+ return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
1463
+
1464
+ def _reraise(exc: WorkflowError) -> ContextError:
1465
+ return ContextError(exc.code, "workflow", str(exc), args.task_dir)
1466
+
1467
+ task_file = locate_task_file(_directory(), args.task)
1340
1468
  if args.active_action == "start":
1341
1469
  owned = tuple(
1342
1470
  _string(item, "owned_paths[]", "")
1343
1471
  for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1344
1472
  )
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)}
1473
+ if task_file is None:
1474
+ active = start_active_task(
1475
+ args.root, args.task_dir, args.task, args.context_sha256, owned
1476
+ )
1477
+ return {"ok": True, "active": _active_data(active)}
1478
+ from cf_workflow_service import start_task
1479
+
1480
+ try:
1481
+ return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
1482
+ except WorkflowError as exc:
1483
+ raise _reraise(exc) from exc
1349
1484
  if args.active_action == "pause":
1350
1485
  active = pause_active_task(args.root)
1351
1486
  elif args.active_action == "resume":
1352
- active = resume_active_task(args.root)
1487
+ if task_file is None:
1488
+ active = resume_active_task(args.root)
1489
+ else:
1490
+ from cf_workflow_service import resume_task
1491
+
1492
+ try:
1493
+ return resume_task(args.root, _directory(), str(task_file), args.task)
1494
+ except WorkflowError as exc:
1495
+ raise _reraise(exc) from exc
1353
1496
  elif args.active_action == "block":
1354
- active = block_active_task(args.root)
1497
+ if task_file is None:
1498
+ active = block_active_task(args.root)
1499
+ else:
1500
+ from cf_workflow_service import block_task
1501
+
1502
+ reason = payload.get("reason", "")
1503
+ try:
1504
+ return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
1505
+ except WorkflowError as exc:
1506
+ raise _reraise(exc) from exc
1355
1507
  elif args.active_action == "complete":
1356
- active = complete_active_task(args.root, payload.get("gate_passed") is True)
1508
+ gate = payload.get("gate_passed") is True
1509
+ if task_file is None:
1510
+ active = complete_active_task(args.root, gate)
1511
+ else:
1512
+ from cf_workflow_service import complete_task
1513
+
1514
+ try:
1515
+ return complete_task(args.root, _directory(), str(task_file), args.task, gate)
1516
+ except WorkflowError as exc:
1517
+ raise _reraise(exc) from exc
1357
1518
  else:
1358
- result = doctor_active_task(
1359
- args.root,
1360
- args.context_sha256,
1361
- payload.get("abandon") is True,
1362
- payload.get("resync") is True,
1363
- )
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)
1364
1524
  return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1365
1525
  return {"ok": True, "active": _active_data(active)}
1366
1526
 
1367
1527
 
1368
1528
  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)
1529
+ """Run refresh, activation and TASK projection in one guarded process.
1530
+
1531
+ Delegates to the unified workflow service: hard preconditions (status,
1532
+ NOTES, depends, marker) are enforced before any state changes, and the
1533
+ Markdown view flips to in-progress in the same call.
1534
+ """
1535
+ from cf_workflow_service import WorkflowError, start_task
1536
+
1375
1537
  owned = tuple(
1376
1538
  _string(item, "owned_paths[]", "")
1377
1539
  for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1378
1540
  )
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),
1541
+ try:
1542
+ return start_task(
1543
+ args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
1390
1544
  )
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
- }
1545
+ except WorkflowError as exc:
1546
+ raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
1416
1547
 
1417
1548
 
1418
1549
  def _status_command(args: argparse.Namespace) -> dict[str, object]:
@@ -1465,7 +1596,7 @@ def _status_text(data: dict[str, object]) -> str:
1465
1596
  match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
1466
1597
  lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
1467
1598
  if not marker["hash_match"]:
1468
- lines.append(" 下一步: 运行 cf-spec doctor(resync 可自动重同步)")
1599
+ lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
1469
1600
  else:
1470
1601
  lines.append("- 无 active TASK(path/catalog 路由模式)")
1471
1602
  gate = data["gate"]
@@ -1519,6 +1650,9 @@ def _parser() -> argparse.ArgumentParser:
1519
1650
  command.add_argument("--task", required=True)
1520
1651
  command.add_argument("--context-sha256", required=True)
1521
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")
1522
1656
  start = commands.add_parser("start")
1523
1657
  start.add_argument("--task-dir", required=True)
1524
1658
  start.add_argument("--root", required=True)