@michaelxwb/code-flow 0.6.4 → 0.8.1

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 (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -40
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,755 @@
1
+ #!/usr/bin/env python3
2
+ """Unified workflow state machine: single entry for task state transitions.
3
+
4
+ Markdown task files are a readable view; the active marker plus Context are
5
+ the facts. Every transition below validates preconditions, mutates the marker
6
+ through cf_spec_context primitives, and syncs the Markdown view in the same
7
+ call with a recovery journal. Interrupted transitions can be rolled forward;
8
+ concurrent user edits block recovery instead of being overwritten.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import json
15
+ import sys
16
+ from dataclasses import replace
17
+ from datetime import date
18
+ from pathlib import Path
19
+ from typing import Mapping, Optional, Sequence
20
+ from codeflow.spec.cf_active_task import ActiveTask, active_data, load_active_task
21
+ from codeflow.spec.cf_spec_context_model import ContextError, SpecContext, load_context
22
+ from codeflow.task.cf_task_doc import TaskDocument, TaskDocumentError, parse_task_document
23
+ from codeflow.spec.cf_workflow_transaction import recover_transition
24
+ from codeflow.core.cf_task_state import FINISHED_STATUSES
25
+
26
+
27
+ class WorkflowError(ValueError):
28
+ """Fail-closed transition error with a stable code."""
29
+
30
+ def __init__(self, code: str, message: str, details: tuple[str, ...] = ()) -> None:
31
+ self.code = code
32
+ self.details = details
33
+ super().__init__(f"[{code}] {message}" + (f": {'; '.join(details)}" if details else ""))
34
+
35
+
36
+ def locate_task_file(task_dir: str, task_id: str) -> Optional[Path]:
37
+ """Find the unique task file containing the TASK section."""
38
+ matches: list[Path] = []
39
+ for path in sorted(Path(task_dir).glob("*.md")):
40
+ if path.name.endswith((".prd.md", ".design.md", ".scenarios.md")):
41
+ continue
42
+ try:
43
+ document = _task_document(str(path))
44
+ except WorkflowError as exc:
45
+ if exc.code == "task_document_empty":
46
+ continue
47
+ raise
48
+ if any(task.id == task_id for task in document.tasks):
49
+ matches.append(path)
50
+ return matches[0] if len(matches) == 1 else None
51
+
52
+
53
+ def _task_document(task_file: str) -> TaskDocument:
54
+ try:
55
+ text = Path(task_file).read_text(encoding="utf-8")
56
+ except (OSError, UnicodeError) as exc:
57
+ raise WorkflowError("task_read_error", str(exc)) from exc
58
+ try:
59
+ return parse_task_document(text, source=str(task_file))
60
+ except TaskDocumentError as exc:
61
+ raise WorkflowError(exc.code, str(exc), exc.details) from exc
62
+
63
+
64
+ def read_task_section(task_file: str, task_id: str) -> str:
65
+ for task in _task_document(task_file).tasks:
66
+ if task.id == task_id:
67
+ return task.body
68
+ raise WorkflowError("task_not_found", task_id, (task_file,))
69
+
70
+
71
+ def section_status(section: str) -> str:
72
+ """Markdown status; absent Status line means draft (pre-contract tasks)."""
73
+ return parse_task_document(section).tasks[0].status
74
+
75
+
76
+ def task_truth_issues(task_file: str, task_id: str, context: SpecContext,
77
+ spec_ids: Optional[set[str]] = None) -> tuple[str, ...]:
78
+ """Return required Rule refs whose status contradicts the Markdown claim.
79
+
80
+ The Markdown is a view. A TASK claiming done/verified while its required
81
+ code (or, for verified, review) Rules are not verified is a documentation
82
+ lie; archive/verify callers must fail closed instead of trusting the prose.
83
+ """
84
+ section = read_task_section(task_file, task_id)
85
+ status = section_status(section)
86
+ if status not in FINISHED_STATUSES:
87
+ return ()
88
+ stages = ("code", "review") if status == "verified" else ("code",)
89
+ issues: list[str] = []
90
+ for binding in context.bindings:
91
+ for rule in binding.rules:
92
+ if rule.enforcement != "required" or rule.verifier_stage not in stages:
93
+ continue
94
+ stage_status = rule.stage_status.get(rule.verifier_stage)
95
+ actual = stage_status.status if stage_status is not None else "missing"
96
+ if status == "done" and spec_ids is not None and actual == "unverified":
97
+ from codeflow.spec.cf_stage_gate import evidence_deferral_kind
98
+ latest = stage_status.evidence[-1] if stage_status.evidence else None
99
+ deferred = evidence_deferral_kind(latest)
100
+ if deferred == "heavy" or (deferred == "review_stage" and binding.spec_id not in spec_ids):
101
+ continue
102
+ if actual != "verified":
103
+ issues.append(f"{binding.spec_id}#{rule.ref}: {actual}")
104
+ return tuple(sorted(set(issues)))
105
+
106
+
107
+ def section_depends(section: str) -> tuple[str, ...]:
108
+ return parse_task_document(section).tasks[0].depends
109
+
110
+
111
+ def section_notes(section: str) -> tuple[str, ...]:
112
+ return tuple(
113
+ line.strip()
114
+ for line in section.splitlines()
115
+ if "#NOTES" in line
116
+ )
117
+
118
+
119
+ def _all_statuses(task_file: str) -> Mapping[str, str]:
120
+ return {task.id: task.status for task in _task_document(task_file).tasks}
121
+
122
+
123
+ def check_startable(task_file: str, task_id: str) -> list[str]:
124
+ """Collectors of start blockers; empty means the TASK may start."""
125
+ section = read_task_section(task_file, task_id)
126
+ status = section_status(section)
127
+ blockers: list[str] = []
128
+ if status == "blocked":
129
+ blockers.append(f"{task_id} is blocked; resolve Log BLOCKED entries first")
130
+ elif status == "done":
131
+ blockers.append(f"{task_id} is already done; verify contract or reopen explicitly")
132
+ elif status not in ("draft", "in-progress", "provisional"):
133
+ blockers.append(f"{task_id} has unexpected status '{status}'")
134
+ notes = section_notes(section)
135
+ if notes:
136
+ blockers.append(f"{len(notes)} unresolved #NOTES: {notes[0][:80]}")
137
+ statuses = _all_statuses(task_file)
138
+ unmet = [dep for dep in section_depends(section) if statuses.get(dep) not in FINISHED_STATUSES]
139
+ if unmet:
140
+ rendered = ", ".join(dep + "(" + str(statuses.get(dep, "missing")) + ")" for dep in unmet)
141
+ blockers.append("unmet depends: " + rendered)
142
+ return blockers
143
+
144
+
145
+ def _today() -> str:
146
+ return date.today().isoformat()
147
+
148
+
149
+ def _log_list_end(section: str, heading: re.Match) -> int:
150
+ """`### Log` 列表最后一项的末尾偏移;无条目时为标题行末。"""
151
+ body_start = heading.end()
152
+ boundary = re.search(r"(?m)^#{2,3} |^---[ \t]*$", section[body_start:])
153
+ body_end = body_start + (boundary.start() if boundary is not None else len(section) - body_start)
154
+ entries = list(re.finditer(r"(?m)^- .*$", section[body_start:body_end]))
155
+ return body_start + (entries[-1].end() if entries else 0)
156
+
157
+
158
+ def _trailing_separator_at(section: str) -> int:
159
+ """段落尾部 `---` 分隔符的起始偏移(取最后一个);无分隔符时为段落末尾。"""
160
+ at = len(section)
161
+ for match in re.finditer(r"(?m)^---[ \t]*$", section):
162
+ at = match.start()
163
+ return at
164
+
165
+
166
+ def _append_log_entry(section: str, log_line: str) -> str:
167
+ """把生命周期记录追加进 `### Log` 列表(无该小节时在尾部分隔符前新建)。
168
+
169
+ 历史缺陷:直接追加到 section 末尾会落在 `---` 分隔符之后,并吞掉下一节前的
170
+ 空行;这里始终在段落内容区内插入,保持分隔符与空行原样。
171
+ """
172
+ heading = re.search(r"(?m)^### Log[ \t]*$", section)
173
+ if heading is not None:
174
+ at = _log_list_end(section, heading)
175
+ return section[:at] + f"\n{log_line}" + section[at:]
176
+ at = _trailing_separator_at(section)
177
+ head, tail = section[:at].rstrip(), section[at:].lstrip("\n")
178
+ body = f"{head}\n\n### Log\n{log_line}\n"
179
+ return body + (f"\n{tail}" if tail else "")
180
+
181
+
182
+ def _set_status(section: str, status: str, log_line: str = "") -> str:
183
+ if re.search(r"(?m)^- \*\*Status\*\*:", section):
184
+ section = re.sub(r"(?m)^(- \*\*Status\*\*:)[^\r\n]*", rf"\1 {status}", section, count=1)
185
+ else:
186
+ line_end = section.find("\n")
187
+ if line_end == -1:
188
+ insert_at = len(section)
189
+ elif line_end > 0 and section[line_end - 1] == "\r":
190
+ insert_at = line_end - 1
191
+ else:
192
+ insert_at = line_end
193
+ eol = "\r\n" if "\r\n" in section else "\n"
194
+ section = section[:insert_at] + eol + f"- **Status**: {status}" + section[insert_at:]
195
+ if log_line and log_line not in section.splitlines():
196
+ section = _append_log_entry(section, log_line)
197
+ return section
198
+
199
+
200
+ def _section_span(text: str, task_id: str) -> tuple[int, int]:
201
+ """Offsets of one TASK section in the original text (line endings preserved)."""
202
+ heading = f"## {task_id}:"
203
+ start = end = -1
204
+ offset = 0
205
+ for line in text.splitlines(keepends=True):
206
+ content = line.rstrip("\r\n")
207
+ if content.startswith(heading):
208
+ if start != -1:
209
+ raise WorkflowError("duplicate_task_id", task_id)
210
+ start = offset
211
+ elif start != -1 and content.startswith("## "):
212
+ end = offset
213
+ break
214
+ offset += len(line)
215
+ if start == -1:
216
+ raise WorkflowError("task_not_found", task_id)
217
+ return start, len(text) if end == -1 else end
218
+
219
+
220
+ def _render_markdown(text: str, task_id: str, status: str, log_line: str = "", prepend: str = "") -> str:
221
+ try:
222
+ document = parse_task_document(text)
223
+ except TaskDocumentError as exc:
224
+ raise WorkflowError(exc.code, str(exc), exc.details) from exc
225
+ if not any(task.id == task_id for task in document.tasks):
226
+ raise WorkflowError("task_not_found", task_id)
227
+ start, end = _section_span(text, task_id)
228
+ section = text[start:end]
229
+ updated = _set_status(section, status, log_line)
230
+ if not prepend and status in ("done", "verified"):
231
+ updated = re.sub(r"(?m)^> (?:PROVISIONAL|BLOCKED):[^\n]*\n?", "", updated)
232
+ if prepend and prepend not in updated:
233
+ if "### Log" in updated:
234
+ updated = updated.replace("### Log", prepend + "\n### Log", 1)
235
+ else:
236
+ at = _trailing_separator_at(updated)
237
+ head, tail = updated[:at].rstrip(), updated[at:].lstrip("\n")
238
+ updated = f"{head}\n\n{prepend}\n" + (f"\n{tail}" if tail else "")
239
+ text = text[:start] + updated + text[end:]
240
+ return re.sub(r"(?m)^(- \*\*Updated\*\*:)[^\r\n]*", rf"\1 {_today()}", text, count=1)
241
+
242
+
243
+ def _sync_markdown(task_file: str, task_id: str, status: str, log_line: str = "", prepend: str = "") -> str:
244
+ from codeflow.core.cf_fs import atomic_write_text
245
+ target = Path(task_file)
246
+ text = target.read_text(encoding="utf-8")
247
+ atomic_write_text(target, _render_markdown(text, task_id, status, log_line, prepend))
248
+ return "synced"
249
+
250
+
251
+ def _requirement_task_mismatches(directory: str, tasks: Sequence[tuple[Path, str, str]],
252
+ context: Optional[SpecContext]) -> list[dict[str, object]]:
253
+ mismatches: list[dict[str, object]] = []
254
+ supplied = {task_id for _, task_id, _ in tasks}
255
+ if not supplied or len(supplied) != len(tasks):
256
+ return [{"task": "", "issues": ["TASK list is empty or contains duplicates"]}]
257
+ actual: set[str] = set()
258
+ for path in sorted(Path(directory).glob("*.md")):
259
+ if path.name.endswith((".design.md", ".prd.md", ".scenarios.md")):
260
+ continue
261
+ try:
262
+ actual.update(task.id for task in _task_document(str(path)).tasks)
263
+ except WorkflowError as exc:
264
+ if exc.code != "task_document_empty":
265
+ raise
266
+ for task_id in sorted(actual - supplied):
267
+ mismatches.append({"task": task_id, "issues": ["TASK was omitted from requirement verification"]})
268
+ for task_file, task_id, _ in tasks:
269
+ located = locate_task_file(directory, task_id)
270
+ issues: list[str] = []
271
+ if located is None or located.resolve() != task_file.resolve():
272
+ issues.append("TASK file does not belong to this requirement")
273
+ elif section_status(read_task_section(str(task_file), task_id)) not in FINISHED_STATUSES:
274
+ issues.append("TASK is not done or verified")
275
+ elif context is not None:
276
+ issues.extend(task_truth_issues(str(task_file), task_id, context))
277
+ if issues:
278
+ mismatches.append({"task": task_id, "issues": issues})
279
+ return mismatches
280
+
281
+
282
+ def _compact_verified_contexts(root: str, directory: str) -> None:
283
+ from codeflow.evidence.cf_evidence_store import compact_context_file, requirement_key
284
+ for path in sorted(Path(directory).rglob("spec-context.yml")):
285
+ try:
286
+ compact_context_file(path, root=root, requirement=requirement_key(directory))
287
+ except (OSError, ValueError) as exc:
288
+ sys.stderr.write(f"cf_task_workflow: context compaction failed: {exc}\n")
289
+
290
+
291
+ def _requirement_layer_issues(result: Mapping[str, object]) -> list[str]:
292
+ required = {"validators", "review", "e2e"}
293
+ selected = result.get("selected_layers")
294
+ layers = result.get("layers")
295
+ if result.get("decision") != "pass" or result.get("profile") != "full":
296
+ return ["requirement verification must pass under the full profile"]
297
+ if not isinstance(selected, list) or set(selected) != required or not isinstance(layers, Mapping):
298
+ return ["validators, review and acceptance results are required"]
299
+ return [f"{name} verification did not pass" for name in sorted(required)
300
+ if not isinstance(layers.get(name), Mapping) or layers[name].get("decision") != "pass"]
301
+
302
+
303
+ def _missing_acceptance_refs(tasks: Sequence[tuple[Path, str, str]],
304
+ rows: Sequence[Mapping[str, object]]) -> list[str]:
305
+ scenarios = {row["id"]: row for row in rows}
306
+ issues: list[str] = []
307
+ for task_file, task_id, _ in tasks:
308
+ section = parse_task_document(read_task_section(str(task_file), task_id)).tasks[0]
309
+ refs = set(re.findall(r"\b(?:S|E|B)-\d+\b", section.fields.get("Acceptance-Refs", "")))
310
+ for ref in sorted(refs):
311
+ row = scenarios.get(ref)
312
+ if row is None or row.get("owner") not in (None, "", task_id):
313
+ issues.append(f"{task_id}: acceptance scenario {ref} is missing or belongs to another TASK")
314
+ return issues
315
+
316
+
317
+ def _stale_acceptance_issues(root: str, checks: Mapping[str, Mapping[str, object]],
318
+ rows: Sequence[Mapping[str, object]]) -> list[str]:
319
+ from codeflow.acceptance.cf_acceptance_evidence import stale_scenario_checks
320
+ from codeflow.acceptance.cf_check_inputs import check_input_hashes
321
+ from codeflow.core.cf_exec_base import worktree_fingerprint
322
+
323
+ fingerprint = worktree_fingerprint(root)
324
+ hashes = check_input_hashes(root, checks, fingerprint)
325
+ return [f"{row['id']}: acceptance evidence is stale" for row in rows
326
+ if row.get("status") == "verified" and stale_scenario_checks(row, hashes, fingerprint)]
327
+
328
+
329
+ def _requirement_acceptance_issues(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]]) -> list[str]:
330
+ from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
331
+ from codeflow.acceptance.cf_acceptance_risk import accepted_risk
332
+ from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids, verified_evidence
333
+
334
+ manifest = Path(directory) / ".acceptance-manifest.json"
335
+ if not manifest.is_file():
336
+ required = [task_id for task_file, task_id, _ in tasks
337
+ if parse_task_document(read_task_section(str(task_file), task_id)).tasks[0]
338
+ .fields.get("Acceptance-Refs", "").strip().upper() not in ("", "N/A", "NONE", "-")]
339
+ return [f"{task_id}: acceptance manifest missing" for task_id in required]
340
+ data = load_manifest(manifest)
341
+ validate_execution_baseline(manifest, data)
342
+ task_file = data.get("task_file")
343
+ if not isinstance(task_file, str) or not task_file.strip() or {
344
+ file.resolve() for file, _, _ in tasks
345
+ } != {(manifest.parent / task_file).resolve()}:
346
+ return ["acceptance manifest is not bound to these TASKs"]
347
+ owners = {task_id for _, task_id, _ in tasks}
348
+ checks = {str(check["id"]): check for check in data.get("checks", [])}
349
+ issues = _missing_acceptance_refs(tasks, data["scenarios"])
350
+ rows = [row for row in data["scenarios"] if (not row.get("owner") or row.get("owner") in owners)
351
+ and row.get("exec") != "aspirational"]
352
+ issues.extend(_stale_acceptance_issues(root, checks, rows))
353
+ for row in rows:
354
+ if verified_evidence(row):
355
+ continue
356
+ if row.get("status") == "accepted_risk" and any(
357
+ accepted_risk(checks.get(check_id), row) is not None for check_id in scenario_check_ids(row)
358
+ ):
359
+ continue
360
+ issues.append(f"{row['id']}: acceptance evidence is not verified")
361
+ return issues
362
+
363
+
364
+ def complete_requirement_verification(root: str, directory: str, tasks: Sequence[tuple[Path, str, str]],
365
+ result: dict[str, object]) -> dict[str, object]:
366
+ """Promote finished TASKs only after full layers and persisted evidence pass."""
367
+ layer_issues = _requirement_layer_issues(result)
368
+ if layer_issues or (Path(root) / ".code-flow/.active-task.json").exists():
369
+ return {**result, "decision": "block", "reason": "requirement_verification_incomplete",
370
+ "issues": layer_issues or ["active TASK still exists"]}
371
+ context_path = Path(directory) / "spec-context.yml"
372
+ context = load_context(str(context_path)) if context_path.is_file() else None
373
+ mismatches = _requirement_task_mismatches(directory, tasks, context)
374
+ if mismatches:
375
+ return {**result, "decision": "block", "reason": "task_status_truth_mismatch", "tasks": mismatches}
376
+ if context is not None:
377
+ from codeflow.spec.cf_stage_gate import validate_stage
378
+ issues = [item.message for stage in ("code", "review") for item in validate_stage(context, stage).errors]
379
+ if issues:
380
+ return {**result, "decision": "block", "reason": "requirement_spec_unverified", "issues": issues}
381
+ try:
382
+ issues = _requirement_acceptance_issues(root, directory, tasks)
383
+ except (OSError, ValueError) as exc:
384
+ issues = [str(exc)]
385
+ if issues:
386
+ return {**result, "decision": "block", "reason": "requirement_acceptance_unverified", "issues": issues}
387
+ _compact_verified_contexts(root, directory)
388
+ for task_file, task_id, _ in tasks:
389
+ _sync_markdown(str(task_file), task_id, "verified")
390
+ return result
391
+
392
+
393
+ def _commit_state(root: str, task_file: str, task_id: str, status: str, previous: Optional[ActiveTask],
394
+ updated: Optional[ActiveTask], log_line: str = "", prepend: str = "", *,
395
+ context_change: Optional[tuple[Path, str, str]] = None) -> str:
396
+ from codeflow.spec.cf_workflow_transaction import commit_transition
397
+ marker = Path(root) / ".code-flow/.active-task.json"
398
+ marker_before = marker.read_bytes().decode("utf-8") if marker.exists() else None
399
+ actual = json.loads(marker_before) if marker_before is not None else None
400
+ expected = active_data(previous) if previous is not None else None
401
+ if actual != expected:
402
+ raise WorkflowError("active_mismatch", "active task changed before commit")
403
+ marker_after = json.dumps(active_data(updated), ensure_ascii=False, indent=2) + "\n" if updated is not None else None
404
+ task = Path(task_file)
405
+ before = task.read_bytes().decode("utf-8")
406
+ after = _render_markdown(before, task_id, status, log_line, prepend)
407
+ try:
408
+ commit_transition(root, task, before, after, marker_before, marker_after, context_change=context_change)
409
+ except (OSError, ValueError) as exc:
410
+ raise WorkflowError("transition_failed", str(exc)) from exc
411
+ return "synced"
412
+
413
+
414
+ def _resolve_paths(root: str, task_dir: str, task_file: str) -> tuple[Path, Path]:
415
+ base = Path(root)
416
+ directory = Path(task_dir) if Path(task_dir).is_absolute() else base / task_dir
417
+ candidate = Path(task_file) if Path(task_file).is_absolute() else base / task_file
418
+ if base.resolve() not in candidate.resolve().parents or candidate.parent.resolve() != directory.resolve() or not candidate.is_file():
419
+ raise WorkflowError("invalid_task_file", str(candidate), (str(directory),))
420
+ return directory, candidate
421
+
422
+
423
+ def _validate_start_manifest(directory: Path, candidate: Path) -> None:
424
+ if "## Acceptance Coverage" not in candidate.read_text(encoding="utf-8"):
425
+ return
426
+ manifest_file = directory / ".acceptance-manifest.json"
427
+ if not manifest_file.is_file():
428
+ raise WorkflowError("acceptance_manifest_missing", str(manifest_file), ("run plan verification and lock the manifest",))
429
+ from codeflow.acceptance.cf_acceptance_manifest_core import validate_manifest
430
+ valid, reason = validate_manifest(str(candidate), str(manifest_file))
431
+ if not valid:
432
+ raise WorkflowError(reason or "acceptance_manifest_invalid", str(manifest_file), ("run plan verification before coding",))
433
+
434
+
435
+ def _write_projection(root: str, candidate: Path, context: SpecContext, task_id: str, session_output: str) -> Path:
436
+ from codeflow.core.cf_fs import atomic_write_text
437
+ from codeflow.spec.cf_task_projection import project_task_session
438
+ try:
439
+ projection = project_task_session(context, str(candidate), task_id)
440
+ except ValueError as exc:
441
+ raise WorkflowError("task_projection_failed", str(exc)) from exc
442
+ if projection.truncated:
443
+ raise WorkflowError("task_projection_truncated", task_id, ("split the TASK before coding",))
444
+ output = Path(session_output) if session_output else (
445
+ Path(root) / ".code-flow/specs/_session" / f"task-{candidate.stem}.md"
446
+ )
447
+ try:
448
+ output.parent.mkdir(parents=True, exist_ok=True)
449
+ atomic_write_text(output, projection.text)
450
+ except (OSError, ValueError) as exc:
451
+ raise WorkflowError("session_write_failed", str(exc)) from exc
452
+ return output
453
+
454
+
455
+ def remove_session_projection(root: str, task_file: str) -> str:
456
+ """删除任务在 `specs/_session/` 的投影,返回 removed / absent / error: ...。
457
+
458
+ 投影是 start 生成的瞬时文件(`.gitignore` 忽略、不参与 Spec Catalog 与审计),
459
+ 任务完成后必须清理,避免往期任务的 Required Rules/Contract 被后续会话读到。
460
+ 清理失败不影响 Done 裁决,但状态必须显式返回。
461
+ """
462
+ projection = Path(root) / ".code-flow/specs/_session" / f"task-{Path(task_file).stem}.md"
463
+ try:
464
+ projection.unlink()
465
+ except FileNotFoundError:
466
+ return "absent"
467
+ except OSError as exc:
468
+ return f"error: {exc}"
469
+ return "removed"
470
+
471
+
472
+ def cleanup_session_projections(root: str, directory: str) -> dict[str, object]:
473
+ """按需求目录逐个任务文件删除 `_session` 投影(只动本需求的 task-<stem>.md)。"""
474
+ removed: list[str] = []
475
+ absent: list[str] = []
476
+ errors: list[dict[str, str]] = []
477
+ for task_file in sorted(Path(directory).glob("*.md")):
478
+ if task_file.name.endswith((".design.md", ".prd.md")):
479
+ continue
480
+ relative = f".code-flow/specs/_session/task-{task_file.stem}.md"
481
+ status = remove_session_projection(root, str(task_file))
482
+ if status == "removed":
483
+ removed.append(relative)
484
+ elif status == "absent":
485
+ absent.append(relative)
486
+ else:
487
+ errors.append({"path": relative, "error": status})
488
+ return {
489
+ "ok": not errors,
490
+ "decision": "block" if errors else "pass",
491
+ "removed": removed,
492
+ "absent": absent,
493
+ "errors": errors,
494
+ }
495
+
496
+
497
+ def _worktree_registration_issue(root: str, task_id: str) -> str:
498
+ """Refuse a parallel worktree that is not registered in its run.json.
499
+
500
+ A background worker must not self-provision a worktree: the run ledger is
501
+ what collect/merge later reads. Plain single-task runs are unaffected
502
+ because their root is not under `.code-flow/worktrees/`.
503
+ """
504
+ path = Path(root).resolve()
505
+ parts = path.parts
506
+ try:
507
+ flow_index = parts.index(".code-flow")
508
+ except ValueError:
509
+ return ""
510
+ if flow_index + 1 >= len(parts) or parts[flow_index + 1] != "worktrees":
511
+ return ""
512
+ run_dir = path.parent
513
+ meta_path = run_dir / "run.json"
514
+ if not meta_path.is_file():
515
+ return f"{path} is not registered in a parallel run (missing {meta_path})"
516
+ try:
517
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
518
+ except (OSError, ValueError) as exc:
519
+ return f"run.json unreadable: {exc}"
520
+ if not isinstance(meta, Mapping):
521
+ return f"run.json must be an object: {meta_path}"
522
+ if (meta.get("version") != 1
523
+ or not isinstance(meta.get("task_file"), str) or not meta.get("task_file")
524
+ or not isinstance(meta.get("base_head"), str) or not meta.get("base_head")):
525
+ return f"run.json is incomplete: {meta_path}"
526
+ entries = meta.get("worktrees")
527
+ if not isinstance(entries, list):
528
+ return f"run.json worktrees must be a list: {meta_path}"
529
+ for entry in entries:
530
+ if not isinstance(entry, Mapping) or entry.get("task") != task_id:
531
+ continue
532
+ raw = entry.get("path")
533
+ if not isinstance(raw, str) or not Path(raw).is_absolute():
534
+ return f"run.json path for {task_id} must be an absolute path"
535
+ entry_path = Path(raw).resolve()
536
+ if entry_path == path:
537
+ return ""
538
+ return f"worktree path mismatch for {task_id}: run.json={entry_path}, cwd={path}"
539
+ return f"{task_id} is not registered in {meta_path}"
540
+
541
+
542
+ def _start_paths(root: str, task_dir: str, task_file: str, task_id: str) -> tuple[Path, Path, Path]:
543
+ recover_transition(root)
544
+ directory, candidate = _resolve_paths(root, task_dir, task_file)
545
+ registration = _worktree_registration_issue(root, task_id)
546
+ if registration:
547
+ raise WorkflowError("worktree_not_registered", task_id, (registration,))
548
+ blockers = check_startable(str(candidate), task_id)
549
+ marker_active = (Path(root) / ".code-flow" / ".active-task.json").exists()
550
+ if marker_active:
551
+ blockers = [f"active_exists: run cf-spec doctor before starting {task_id}"] + blockers
552
+ if blockers:
553
+ raise WorkflowError("start_blocked", task_id, tuple(blockers))
554
+
555
+ context_path = directory / "spec-context.yml"
556
+ if not context_path.is_file():
557
+ raise WorkflowError(
558
+ "context_missing",
559
+ str(context_path),
560
+ ("run cf-task:plan (bind --stage plan) or cf-spec refresh to create spec-context.yml before start",),
561
+ )
562
+ return directory, candidate, context_path
563
+
564
+
565
+ def _start_e2e_plan(directory: Path, candidate: Path, task_id: str) -> dict[str, object]:
566
+ _validate_start_manifest(directory, candidate)
567
+ from codeflow.acceptance.cf_acceptance_plan import build_e2e_plan
568
+ try:
569
+ e2e_plan = build_e2e_plan(
570
+ str(directory / ".acceptance-manifest.json"), str(candidate), task_id
571
+ )
572
+ except (OSError, ValueError) as exc:
573
+ raise WorkflowError("acceptance_manifest_invalid", task_id, (str(exc),)) from exc
574
+ quality = e2e_plan.get("quality")
575
+ if isinstance(quality, Mapping) and quality.get("ok") is not True:
576
+ errors = quality.get("errors") if isinstance(quality.get("errors"), list) else []
577
+ details = tuple(
578
+ f"{item.get('scenario', '')}:{item.get('message', '')}".strip(":")
579
+ for item in errors[:5] if isinstance(item, Mapping)
580
+ )
581
+ raise WorkflowError("e2e_plan_invalid", task_id, details or ("E2E plan quality gate failed",))
582
+ return e2e_plan
583
+
584
+
585
+ def _start_active(root: str, directory: Path, task_id: str, current_hash: str,
586
+ owned_paths: Sequence[str]) -> ActiveTask:
587
+ from codeflow.spec.cf_active_task import new_active
588
+ if any(not isinstance(item, str) for item in owned_paths):
589
+ raise WorkflowError("invalid_owned_paths", "owned_paths must contain only strings")
590
+ try:
591
+ return replace(new_active(root, str(directory), task_id, current_hash, tuple(owned_paths)), status="active")
592
+ except (OSError, ValueError) as exc:
593
+ code = getattr(exc, "code", "activation_failed")
594
+ raise WorkflowError(str(code), str(exc)) from exc
595
+
596
+
597
+ def start_task(
598
+ root: str,
599
+ task_dir: str,
600
+ task_file: str,
601
+ task_id: str,
602
+ owned_paths: Sequence[str] = (),
603
+ session_output: str = "",
604
+ ) -> dict[str, object]:
605
+ """Run the Start Gate before persisting the Context, marker and TASK view."""
606
+ from codeflow.spec.cf_context_refresh import refresh_context
607
+ from codeflow.spec.cf_spec_context_model import context_sha256, serialize_context
608
+
609
+ directory, candidate, context_path = _start_paths(root, task_dir, task_file, task_id)
610
+ context_bytes = context_path.read_bytes()
611
+ refreshed = refresh_context(load_context(str(context_path), source=context_bytes), root, artifact_root=str(directory))
612
+ current_hash = context_sha256(refreshed.context)
613
+ e2e_plan = _start_e2e_plan(directory, candidate, task_id)
614
+ active = _start_active(root, directory, task_id, current_hash, owned_paths)
615
+ # Check and write the bounded projection before saving a refreshed Context;
616
+ # invalid refs, an output error, or unowned changes must leave it untouched.
617
+ output = _write_projection(root, candidate, refreshed.context, task_id, session_output)
618
+ try:
619
+ context_change = (context_path, context_bytes.decode("utf-8"), serialize_context(refreshed.context))
620
+ markdown = _commit_state(root, str(candidate), task_id, "in-progress", None, active,
621
+ f"- [{_today()}] started", context_change=context_change)
622
+ except (OSError, ValueError) as exc:
623
+ code = getattr(exc, "code", "activation_failed")
624
+ raise WorkflowError(str(code), str(exc)) from exc
625
+
626
+ return {
627
+ "ok": True,
628
+ "context_sha256": current_hash,
629
+ "refresh_changes": [item.__dict__ for item in refreshed.changes],
630
+ "active": active_data(active),
631
+ "session_output": str(output),
632
+ "acceptance_manifest": str(directory / ".acceptance-manifest.json") if (directory / ".acceptance-manifest.json").is_file() else None,
633
+ "e2e_plan": e2e_plan,
634
+ "markdown": markdown,
635
+ }
636
+
637
+
638
+ def require_active_task(root: str, task_id: str, task_dir: str, task_file: str) -> None:
639
+ recover_transition(root)
640
+ try:
641
+ active = load_active_task(root)
642
+ except (ContextError, OSError, ValueError) as exc:
643
+ raise WorkflowError("no_active_task", str(exc)) from exc
644
+ directory, candidate = _resolve_paths(root, task_dir, task_file)
645
+ if active.task_id != task_id or (Path(root) / active.task_dir).resolve() != directory.resolve():
646
+ raise WorkflowError("active_mismatch", f"marker={active.task_id} requested={task_id}")
647
+ located = locate_task_file(str(directory), task_id)
648
+ if located is None or located.resolve() != candidate.resolve():
649
+ raise WorkflowError("active_mismatch", "TASK file must be unique and match the active demand")
650
+
651
+
652
+ def block_task(root: str, task_dir: str, task_file: str, task_id: str, reason: str) -> dict[str, object]:
653
+ """Block a TASK: marker (when it is ours and active) plus Markdown view."""
654
+ if not reason.strip():
655
+ raise WorkflowError("block_reason_required", task_id)
656
+ recover_transition(root)
657
+ _, target = _resolve_paths(root, task_dir, task_file)
658
+ task_file = str(target)
659
+ section = read_task_section(task_file, task_id)
660
+ if section_status(section) in FINISHED_STATUSES:
661
+ raise WorkflowError("task_already_done", task_id, ("completed TASKs cannot be blocked",))
662
+ marker = "skipped_no_marker"
663
+ active = None
664
+ updated = None
665
+ try:
666
+ active = load_active_task(root)
667
+ require_active_task(root, task_id, task_dir, task_file)
668
+ if active.task_id == task_id and active.status == "active":
669
+ updated = replace(active, status="blocked")
670
+ marker = "blocked"
671
+ elif active.task_id == task_id:
672
+ marker = active.status
673
+ updated = active
674
+ except ContextError:
675
+ if (Path(root) / ".code-flow/.active-task.json").exists():
676
+ raise
677
+ marker = "skipped_no_marker"
678
+ today = _today()
679
+ markdown = _commit_state(
680
+ root, task_file, task_id, "blocked", active, updated,
681
+ f"- [{today}] blocked ({reason})",
682
+ prepend=f"> BLOCKED: {reason}",
683
+ )
684
+ return {"ok": True, "task_id": task_id, "marker": marker, "markdown": markdown}
685
+
686
+
687
+ def resume_task(root: str, task_dir: str, task_file: str, task_id: str) -> dict[str, object]:
688
+ """Resume a blocked/paused TASK after its blockers clear."""
689
+ recover_transition(root)
690
+ _, target = _resolve_paths(root, task_dir, task_file)
691
+ task_file = str(target)
692
+ active = None
693
+ section = read_task_section(task_file, task_id)
694
+ if (Path(root) / ".code-flow/.active-task.json").exists():
695
+ require_active_task(root, task_id, task_dir, task_file)
696
+ active = load_active_task(root)
697
+ allowed_active = active.status in ("blocked", "paused", "provisional")
698
+ provisional_active = active.status == "active" and section_status(section) == "provisional"
699
+ if not allowed_active and not provisional_active:
700
+ raise WorkflowError("resume_unexpected_state", active.status, (task_id,))
701
+ if active is None and section_status(section) not in ("blocked", "provisional"):
702
+ raise WorkflowError("resume_unexpected_state", section_status(section), (task_id,))
703
+ notes = section_notes(section)
704
+ statuses = _all_statuses(task_file)
705
+ unmet = [dep for dep in section_depends(section) if statuses.get(dep) not in FINISHED_STATUSES]
706
+ if notes or unmet:
707
+ raise WorkflowError(
708
+ "resume_blocked", task_id,
709
+ tuple(([f"{len(notes)} unresolved #NOTES"] if notes else []) + ([f"unmet depends: {', '.join(unmet)}"] if unmet else [])),
710
+ )
711
+ resumed = replace(active, status="active") if active is not None else None
712
+ status = "in-progress" if resumed is not None else "draft"
713
+ markdown = _commit_state(root, task_file, task_id, status, active, resumed, f"- [{_today()}] resumed ({status})")
714
+ return {"ok": True, "task_id": task_id, "marker": resumed.status if resumed else "absent", "markdown": markdown}
715
+
716
+
717
+ def complete_task(root: str, task_dir: str, task_file: str, task_id: str, gate_passed: bool) -> dict[str, object]:
718
+ """Complete a TASK after its Done Gate passed; syncs both records."""
719
+ if not gate_passed:
720
+ raise WorkflowError("gate_failed", task_id, ("Done Gate 未通过",))
721
+ require_active_task(root, task_id, task_dir, task_file)
722
+ _, target = _resolve_paths(root, task_dir, task_file)
723
+ task_file = str(target)
724
+ active = load_active_task(root)
725
+ if active.status != "active":
726
+ raise WorkflowError("invalid_active_transition", active.status)
727
+ completed = replace(active, status="completed")
728
+
729
+ markdown = _commit_state(root, task_file, task_id, "done", active, None, f"- [{_today()}] completed (done)")
730
+ return {"ok": True, "task_id": task_id, "active": active_data(completed), "markdown": markdown}
731
+
732
+
733
+ def provisional_task(root: str, task_dir: str, task_file: str, task_id: str, reason: str) -> dict[str, object]:
734
+ """Record a provisional finish without claiming done.
735
+
736
+ The active marker stays active so the same TASK can be retried after the
737
+ missing checks are fixed; provisional is explicitly open/unfinished for
738
+ dependency, collect and archive gates.
739
+ """
740
+ recover_transition(root)
741
+ require_active_task(root, task_id, task_dir, task_file)
742
+ _, target = _resolve_paths(root, task_dir, task_file)
743
+ task_file = str(target)
744
+ if section_status(read_task_section(task_file, task_id)) in FINISHED_STATUSES:
745
+ raise WorkflowError("task_already_done", task_id, ("completed TASKs cannot become provisional",))
746
+ active = load_active_task(root)
747
+ if active.status != "active":
748
+ raise WorkflowError("invalid_active_transition", active.status)
749
+ clean_reason = " ".join(reason.split())
750
+ markdown = _commit_state(
751
+ root, task_file, task_id, "provisional", active, active,
752
+ f"- [{_today()}] provisional ({clean_reason})",
753
+ prepend=f"> PROVISIONAL: {clean_reason}",
754
+ )
755
+ return {"ok": True, "task_id": task_id, "marker": "active", "markdown": markdown}