@michaelxwb/code-flow 0.6.5 → 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 -42
  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 +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  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 -42
  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 +52 -39
  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 +69 -35
  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 -42
  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 +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  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 -42
  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 +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  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 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  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 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  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} +5 -2
  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} +163 -35
  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 -304
  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
@@ -1,304 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Active-task scope expansion and hash-bound Done verification."""
3
-
4
- from __future__ import annotations
5
-
6
- from dataclasses import dataclass, replace
7
- import hashlib
8
- import json
9
- from pathlib import Path
10
- import time
11
- from typing import Mapping, Optional
12
-
13
- from cf_spec_context import (
14
- BindingInput,
15
- RuleBinding,
16
- RuleStageStatus,
17
- SpecBinding,
18
- SpecContext,
19
- bind_specs,
20
- current_owned_paths,
21
- load_active_task,
22
- load_context,
23
- pause_active_task,
24
- resync_active_hash,
25
- save_context,
26
- )
27
- from cf_spec_gate import validate_stage
28
- from cf_spec_metadata import load_spec_metadata
29
- from cf_spec_resolver import resolve_candidates, SpecCandidate
30
- from cf_spec_session import context_sha256
31
- from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
32
- from cf_core import load_config, phase_timing, resolve_quality_loop
33
- from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
34
- from cf_exec_base import execution_session
35
-
36
-
37
- @dataclass(frozen=True)
38
- class ScopeResult:
39
- decision: str
40
- files: tuple[str, ...]
41
- new_specs: tuple[str, ...]
42
- message: str
43
-
44
-
45
- @dataclass(frozen=True)
46
- class DoneResult:
47
- decision: str
48
- files: tuple[str, ...]
49
- evidence: tuple[Mapping[str, object], ...]
50
- message: str = ""
51
- deferred_review: int = 0
52
-
53
-
54
- def _context_path(task_dir: str) -> str:
55
- return str(Path(task_dir) / "spec-context.yml")
56
-
57
-
58
- def _manual_manifest_issue(task_dir: str, owner: str = "") -> str:
59
- path = Path(task_dir) / ".acceptance-manifest.json"
60
- if not path.is_file():
61
- return ""
62
- try:
63
- import json
64
-
65
- data = load_manifest(path)
66
- pending = [
67
- item.get("id", "unknown")
68
- for item in data.get("scenarios", [])
69
- if item.get("kind") == "manual" and not verified_evidence(item)
70
- and (not owner or not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") == owner)
71
- ]
72
- return f"manual 场景未完成: {', '.join(pending)}" if pending else ""
73
- except (OSError, ValueError, TypeError):
74
- return "acceptance manifest invalid"
75
-
76
-
77
- def _acceptance_baseline_issue(task_dir: str, owner: str) -> str:
78
- from cf_workflow_service import locate_task_file
79
- path = Path(task_dir) / ".acceptance-manifest.json"
80
- task = locate_task_file(task_dir, owner)
81
- try:
82
- requires = task is not None and "## Acceptance Coverage" in task.read_text(encoding="utf-8")
83
- if not path.is_file():
84
- return "acceptance_manifest_missing" if requires else ""
85
- data = load_manifest(path)
86
- if requires:
87
- from cf_acceptance_manifest import validate_manifest
88
- valid, reason = validate_manifest(str(task), str(path))
89
- if not valid:
90
- return reason
91
- validate_execution_baseline(path, data)
92
- return ""
93
- except (OSError, ValueError) as exc:
94
- return f"acceptance_manifest_invalid: {exc}"
95
-
96
-
97
- def _required_candidate(candidate: SpecCandidate) -> bool:
98
- metadata = candidate.metadata
99
- return metadata.enforcement == "required" and any(
100
- rule.enforcement == "required" for rule in metadata.rules
101
- )
102
-
103
-
104
- def evaluate_scope(root: str, task_dir: str) -> ScopeResult:
105
- active = load_active_task(root)
106
- files = current_owned_paths(root, active)
107
- context = load_context(_context_path(task_dir))
108
- candidates = resolve_candidates(root, "code", files)
109
- existing = {binding.spec_id for binding in context.bindings}
110
- new = tuple(
111
- candidate
112
- for candidate in candidates
113
- if candidate.scope == "path" and candidate.spec_id not in existing
114
- )
115
- if not new:
116
- return ScopeResult("continue", files, (), "scope unchanged")
117
- selections = tuple(
118
- BindingInput(candidate, "scope:path", f"Git diff matched {','.join(candidate.matched_paths)}")
119
- for candidate in new
120
- )
121
- save_context(_context_path(task_dir), bind_specs(context, selections))
122
- # The toolchain expanded the context itself; re-sync the marker so this
123
- # automatic change does not surface as active_context_drift with no recovery.
124
- resync_active_hash(root, task_dir, context_sha256(load_context(_context_path(task_dir))))
125
- required = tuple(candidate.spec_id for candidate in new if _required_candidate(candidate))
126
- if required:
127
- pause_active_task(root)
128
- message = "新增 required Spec 已暂停 TASK;请选择局部 Plan 或回 Align 更新设计"
129
- return ScopeResult("pause", files, required, message)
130
- return ScopeResult("continue", files, tuple(candidate.spec_id for candidate in new), "advisory scope expanded")
131
-
132
-
133
- def _diff_hash(root: str, files: tuple[str, ...]) -> str:
134
- digest = hashlib.sha256()
135
- for relative in files:
136
- path = Path(root) / relative
137
- digest.update(relative.encode())
138
- digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
139
- return digest.hexdigest()
140
-
141
-
142
- def _evidence_data(evidence: VerificationEvidence) -> Mapping[str, object]:
143
- return {
144
- "verifier_ref": evidence.verifier_ref,
145
- "executed_at": evidence.executed_at,
146
- "status": evidence.status,
147
- "rule_text_sha256": evidence.rule_text_sha256,
148
- "artifact_sha256": evidence.artifact_sha256,
149
- "diff_sha256": evidence.diff_sha256,
150
- "result_sha256": evidence.result_sha256,
151
- "error_code": evidence.error_code,
152
- "details": evidence.details,
153
- }
154
-
155
-
156
- def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBinding:
157
- stage = rule.verifier_stage or "code"
158
- if stage not in rule.stage_status:
159
- return rule
160
- statuses = dict(rule.stage_status)
161
- current = statuses[stage]
162
- if current.status in ("not_applicable", "waived"):
163
- return rule
164
- status = "verified" if evidence.get("status") == "verified" else "unverified"
165
- if evidence.get("error_code") == "skipped_in_cheap_gate":
166
- signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), None)
167
- else:
168
- signature = (evidence.get("verifier_ref"), evidence.get("result_sha256"), evidence.get("diff_sha256"))
169
- if any(
170
- (item.get("verifier_ref"), item.get("result_sha256"), item.get("diff_sha256")) == signature
171
- for item in current.evidence
172
- ):
173
- statuses[stage] = replace(current, status=status)
174
- else:
175
- statuses[stage] = replace(current, status=status, evidence=(*current.evidence, evidence))
176
- return replace(rule, stage_status=statuses)
177
-
178
-
179
- def _apply_evidence(context: SpecContext, evidence: tuple[Mapping[str, object], ...]) -> SpecContext:
180
- by_ref = {str(item["verifier_ref"]): item for item in evidence}
181
- bindings: list[SpecBinding] = []
182
- for binding in context.bindings:
183
- rules = tuple(
184
- _update_rule(rule, by_ref[f"{binding.spec_id}#{rule.ref}"])
185
- if f"{binding.spec_id}#{rule.ref}" in by_ref else rule
186
- for rule in binding.rules
187
- )
188
- bindings.append(replace(binding, rules=rules))
189
- return replace(context, bindings=tuple(bindings))
190
-
191
-
192
- def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object]]:
193
- status = rule.stage_status.get("code")
194
- if status is None or status.decision is None or status.decision.kind != "manual_verification":
195
- return None
196
- decision = status.decision
197
- return {
198
- "reason": decision.reason,
199
- "confirmed_by": decision.confirmed_by,
200
- "confirmed_at": decision.confirmed_at,
201
- "source": decision.source,
202
- }
203
-
204
-
205
- def _run_acceptance(root: str, task_dir: str, owner: str, include_e2e: bool,
206
- deadline: Optional[float], cheap: bool = False) -> str:
207
- baseline_issue = _acceptance_baseline_issue(task_dir, owner)
208
- if baseline_issue:
209
- return baseline_issue
210
- manual_issue = _manual_manifest_issue(task_dir, owner)
211
- if manual_issue:
212
- return manual_issue
213
- if cheap:
214
- # 轻量门禁:只做 manifest 基线与 manual 检查,不执行 functional 场景;
215
- # 场景命令由 finish 的全量 Done Gate 执行。
216
- return ""
217
- manifest_path = Path(task_dir) / ".acceptance-manifest.json"
218
- if manifest_path.is_file():
219
- from cf_acceptance_runner import run_manifest
220
- scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e, owner=owner, deadline=deadline)
221
- if scenario_result["decision"] == "block":
222
- reason = scenario_result.get("error") or "acceptance scenario failed"
223
- return f"acceptance scenario failed: {reason}"
224
- return ""
225
-
226
-
227
- def _run_finish_validation(root: str, files: tuple[str, ...]) -> str:
228
- """Full validation.yml (including heavy validators) once per TASK finish.
229
-
230
- Stop-time checks stay cheap; this is the single automatic execution point
231
- for slow suites (task finish `/cf-validate` scope). `quality_loop.finish_check:
232
- false` disables it; projects without validation.yml / package.json scripts are a no-op.
233
- """
234
- if not resolve_quality_loop(load_config(root)).get("finish_check"):
235
- return ""
236
- from cf_validation import validate_files
237
-
238
- try:
239
- result = validate_files(root, files)
240
- except (OSError, ValueError) as exc:
241
- return f"finish validation error: {exc}"
242
- reason = result.get("reason")
243
- if result.get("decision") == "pass" or reason in ("no_changes", "no_validators_configured"):
244
- return ""
245
- failures = result.get("failures")
246
- names = ", ".join(str(item.get("name", "validator")) for item in failures[:3]) if isinstance(failures, list) and failures else "validator"
247
- if result.get("incomplete") and not failures:
248
- return "finish validation 预算耗尽:部分 validator 未执行"
249
- suffix = "(预算耗尽,部分未执行)" if result.get("incomplete") else ""
250
- return f"finish validation failed{suffix}: {names}"
251
-
252
-
253
- def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
254
- active = load_active_task(root)
255
- owner = task_id or active.task_id
256
- if owner != active.task_id or (Path(root) / active.task_dir).resolve() != Path(task_dir).resolve():
257
- return DoneResult("block", (), (), "active_mismatch: task identity does not match marker")
258
- started = time.monotonic()
259
- scope_result = evaluate_scope(root, task_dir)
260
- phase_timing("done.evaluate_scope", started)
261
- if scope_result.decision == "pause":
262
- return DoneResult("block", scope_result.files, (), scope_result.message)
263
- deadline = started + budget if budget is not None else None
264
- issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline, cheap)
265
- if issue:
266
- return DoneResult("block", scope_result.files, (), issue)
267
- phase_started = time.monotonic()
268
- context = load_context(_context_path(task_dir))
269
- diff_hash = _diff_hash(root, scope_result.files)
270
- phase_timing("done.load_context_and_diff", phase_started)
271
- all_evidence: list[Mapping[str, object]] = []
272
- deferred_review = 0
273
- for binding in context.bindings:
274
- phase_started = time.monotonic()
275
- metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
276
- deferred_review += sum(1 for rule in binding.rules if rule.verifier_stage == "review")
277
- confirmations: dict[str, Mapping[str, object]] = {}
278
- for rule in binding.rules:
279
- confirmation = _rule_manual_confirmation(rule)
280
- if confirmation is not None:
281
- confirmations[rule.ref] = confirmation
282
- remaining = None if budget is None else budget - (time.monotonic() - started)
283
- result = run_all_verifiers(
284
- metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining,
285
- stage="code",
286
- )
287
- phase_timing(f"done.verify.{binding.spec_id}", phase_started)
288
- all_evidence.extend(_evidence_data(item) for item in result.evidence)
289
- updated = _apply_evidence(context, tuple(all_evidence))
290
- if updated != context:
291
- save_context(_context_path(task_dir), updated)
292
- gate = validate_stage(updated, "code", diff_sha256=diff_hash, allow_cheap_skips=cheap)
293
- if gate.decision == "pass" and not cheap:
294
- validation_issue = _run_finish_validation(root, scope_result.files)
295
- if validation_issue:
296
- return DoneResult("block", scope_result.files, tuple(all_evidence), validation_issue, deferred_review)
297
- phase_timing("done.total", started)
298
- return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
299
- "; ".join(issue.message for issue in gate.errors), deferred_review)
300
-
301
-
302
- def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
303
- with execution_session():
304
- return _run_done_gate(root, task_dir, cheap, budget, include_e2e, task_id)
@@ -1,3 +0,0 @@
1
- """Shared lifecycle predicates without loading the workflow runtime."""
2
-
3
- FINISHED_STATUSES = ("done", "verified")
@@ -1,366 +0,0 @@
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
- from dataclasses import replace
16
- from datetime import date
17
- from pathlib import Path
18
- from typing import Mapping, Optional, Sequence
19
- from cf_spec_context import ActiveTask, SpecContext
20
- from cf_workflow_transaction import recover_transition
21
- from cf_task_state import FINISHED_STATUSES
22
-
23
-
24
- class WorkflowError(ValueError):
25
- """Fail-closed transition error with a stable code."""
26
-
27
- def __init__(self, code: str, message: str, details: tuple[str, ...] = ()) -> None:
28
- self.code = code
29
- self.details = details
30
- super().__init__(f"[{code}] {message}" + (f": {'; '.join(details)}" if details else ""))
31
-
32
-
33
- def locate_task_file(task_dir: str, task_id: str) -> Optional[Path]:
34
- """Find the unique task file containing the TASK section."""
35
- matches: list[Path] = []
36
- try:
37
- files = sorted(Path(task_dir).glob("*.md"))
38
- except OSError:
39
- return None
40
- for path in files:
41
- if path.name.endswith((".prd.md", ".design.md")):
42
- continue
43
- try:
44
- content = path.read_text(encoding="utf-8")
45
- except (OSError, UnicodeError):
46
- continue
47
- if f"## {task_id}:" in content:
48
- matches.append(path)
49
- return matches[0] if len(matches) == 1 else None
50
-
51
-
52
- def read_task_section(task_file: str, task_id: str) -> str:
53
- try:
54
- text = Path(task_file).read_text(encoding="utf-8")
55
- except (OSError, UnicodeError) as exc:
56
- raise WorkflowError("task_read_error", str(exc)) from exc
57
- start = text.find(f"## {task_id}:")
58
- if start == -1:
59
- raise WorkflowError("task_not_found", task_id, (task_file,))
60
- end = text.find("\n## TASK-", start)
61
- return text[start:] if end == -1 else text[start:end]
62
-
63
-
64
- def section_status(section: str) -> str:
65
- """Markdown status; absent Status line means draft (pre-contract tasks)."""
66
- match = re.search(r"(?m)^- \*\*Status\*\*:[ \t]*([^\n]+)", section)
67
- return match.group(1).strip() if match else "draft"
68
-
69
-
70
- def section_depends(section: str) -> tuple[str, ...]:
71
- match = re.search(r"(?m)^- \*\*Depends\*\*:[ \t]*([^\n]*)", section)
72
- if match is None:
73
- return ()
74
- return tuple(part.strip() for part in re.split(r"[,,\s]+", match.group(1).strip()) if re.fullmatch(r"TASK-\d+", part.strip()))
75
-
76
-
77
- def section_notes(section: str) -> tuple[str, ...]:
78
- return tuple(
79
- line.strip()
80
- for line in section.splitlines()
81
- if "#NOTES" in line
82
- )
83
-
84
-
85
- def _all_statuses(task_file: str) -> Mapping[str, str]:
86
- try:
87
- text = Path(task_file).read_text(encoding="utf-8")
88
- except (OSError, UnicodeError):
89
- return {}
90
- statuses: dict[str, str] = {}
91
- for match in re.finditer(r"(?m)^## (TASK-\d+):[^\n]*$", text):
92
- section = read_task_section(task_file, match.group(1))
93
- statuses[match.group(1)] = section_status(section)
94
- return statuses
95
-
96
-
97
- def check_startable(task_file: str, task_id: str) -> list[str]:
98
- """Collectors of start blockers; empty means the TASK may start."""
99
- section = read_task_section(task_file, task_id)
100
- status = section_status(section)
101
- blockers: list[str] = []
102
- if status == "blocked":
103
- blockers.append(f"{task_id} is blocked; resolve Log BLOCKED entries first")
104
- elif status == "done":
105
- blockers.append(f"{task_id} is already done; verify contract or reopen explicitly")
106
- elif status not in ("draft", "in-progress"):
107
- blockers.append(f"{task_id} has unexpected status '{status}'")
108
- notes = section_notes(section)
109
- if notes:
110
- blockers.append(f"{len(notes)} unresolved #NOTES: {notes[0][:80]}")
111
- statuses = _all_statuses(task_file)
112
- unmet = [dep for dep in section_depends(section) if statuses.get(dep) not in FINISHED_STATUSES]
113
- if unmet:
114
- rendered = ", ".join(dep + "(" + str(statuses.get(dep, "missing")) + ")" for dep in unmet)
115
- blockers.append("unmet depends: " + rendered)
116
- return blockers
117
-
118
-
119
- def _today() -> str:
120
- return date.today().isoformat()
121
-
122
-
123
- def _set_status(section: str, status: str, log_line: str = "") -> str:
124
- if re.search(r"(?m)^- \*\*Status\*\*:", section):
125
- section = re.sub(r"(?m)^- \*\*Status\*\*:.*$", f"- **Status**: {status}", section, count=1)
126
- else:
127
- anchor = re.search(r"(?m)^## TASK-\d+:.*$", section)
128
- insert_at = anchor.end() if anchor else 0
129
- section = section[:insert_at] + f"\n- **Status**: {status}" + section[insert_at:]
130
- if log_line and log_line not in section.splitlines():
131
- section = section.rstrip() + ("\n" if "### Log" in section else "\n\n### Log\n") + log_line + "\n"
132
- return section
133
-
134
-
135
- def _render_markdown(text: str, task_id: str, status: str, log_line: str = "", prepend: str = "") -> str:
136
- match = re.search(rf"(?ms)^## {re.escape(task_id)}:.*?(?=^## |\Z)", text)
137
- if match is None:
138
- raise WorkflowError("task_not_found", task_id)
139
- section = _set_status(match.group(0), status, log_line)
140
- if prepend and prepend not in section:
141
- section = section.replace("### Log", prepend + "\n### Log", 1) if "### Log" in section else section + "\n" + prepend + "\n"
142
- text = text[:match.start()] + section + text[match.end():]
143
- return re.sub(r"(?m)^- \*\*Updated\*\*:.*$", f"- **Updated**: {_today()}", text, count=1)
144
-
145
-
146
- def _sync_markdown(task_file: str, task_id: str, status: str, log_line: str = "", prepend: str = "") -> str:
147
- from cf_spec_context import _atomic_text
148
- target = Path(task_file)
149
- text = target.read_text(encoding="utf-8")
150
- _atomic_text(target, _render_markdown(text, task_id, status, log_line, prepend))
151
- return "synced"
152
-
153
-
154
- def _commit_state(root: str, task_file: str, task_id: str, status: str, previous: Optional[ActiveTask],
155
- updated: Optional[ActiveTask], log_line: str = "", prepend: str = "") -> str:
156
- from cf_spec_context import _active_data
157
- from cf_workflow_transaction import commit_transition
158
- marker = Path(root) / ".code-flow/.active-task.json"
159
- marker_before = marker.read_text(encoding="utf-8") if marker.exists() else None
160
- actual = json.loads(marker_before) if marker_before is not None else None
161
- expected = _active_data(previous) if previous is not None else None
162
- if actual != expected:
163
- raise WorkflowError("active_mismatch", "active task changed before commit")
164
- marker_after = json.dumps(_active_data(updated), ensure_ascii=False, indent=2) + "\n" if updated is not None else None
165
- task = Path(task_file)
166
- before = task.read_text(encoding="utf-8")
167
- after = _render_markdown(before, task_id, status, log_line, prepend)
168
- try:
169
- commit_transition(root, task, before, after, marker_before, marker_after)
170
- except (OSError, ValueError) as exc:
171
- raise WorkflowError("transition_failed", str(exc)) from exc
172
- return "synced"
173
-
174
-
175
- def _resolve_paths(root: str, task_dir: str, task_file: str) -> tuple[Path, Path]:
176
- base = Path(root)
177
- directory = Path(task_dir) if Path(task_dir).is_absolute() else base / task_dir
178
- candidate = Path(task_file) if Path(task_file).is_absolute() else base / task_file
179
- if base.resolve() not in candidate.resolve().parents or candidate.parent.resolve() != directory.resolve() or not candidate.is_file():
180
- raise WorkflowError("invalid_task_file", str(candidate), (str(directory),))
181
- return directory, candidate
182
-
183
-
184
- def _validate_start_manifest(directory: Path, candidate: Path) -> None:
185
- if "## Acceptance Coverage" not in candidate.read_text(encoding="utf-8"):
186
- return
187
- manifest_file = directory / ".acceptance-manifest.json"
188
- if not manifest_file.is_file():
189
- raise WorkflowError("acceptance_manifest_missing", str(manifest_file), ("run plan verification and lock the manifest",))
190
- from cf_acceptance_manifest import validate_manifest
191
- valid, reason = validate_manifest(str(candidate), str(manifest_file))
192
- if not valid:
193
- raise WorkflowError(reason or "acceptance_manifest_invalid", str(manifest_file), ("run plan verification before coding",))
194
-
195
-
196
- def _write_projection(root: str, candidate: Path, context: SpecContext, task_id: str, session_output: str) -> Path:
197
- from cf_spec_context import _atomic_text
198
- from cf_spec_session import project_task_session
199
- try:
200
- projection = project_task_session(context, str(candidate), task_id)
201
- except ValueError as exc:
202
- raise WorkflowError("task_projection_failed", str(exc)) from exc
203
- if projection.truncated:
204
- raise WorkflowError("task_projection_truncated", task_id, ("split the TASK before coding",))
205
- output = Path(session_output) if session_output else (
206
- Path(root) / ".code-flow/specs/_session" / f"task-{candidate.stem}.md"
207
- )
208
- try:
209
- output.parent.mkdir(parents=True, exist_ok=True)
210
- _atomic_text(output, projection.text)
211
- except (OSError, ValueError) as exc:
212
- raise WorkflowError("session_write_failed", str(exc)) from exc
213
- return output
214
-
215
-
216
- def start_task(
217
- root: str,
218
- task_dir: str,
219
- task_file: str,
220
- task_id: str,
221
- owned_paths: Sequence[str] = (),
222
- session_output: str = "",
223
- ) -> dict[str, object]:
224
- """Full Start Gate: hard preconditions first, then the guarded pipeline
225
- (refresh → manifest check → projection → activation), then the Markdown
226
- view flips to in-progress in the same call."""
227
- from cf_spec_context import load_context, refresh_context, save_context, _new_active
228
- from cf_spec_session import context_sha256
229
-
230
- recover_transition(root)
231
- directory, candidate = _resolve_paths(root, task_dir, task_file)
232
- blockers = check_startable(str(candidate), task_id)
233
- marker_active = (Path(root) / ".code-flow" / ".active-task.json").exists()
234
- if marker_active:
235
- blockers = [f"active_exists: run cf-spec doctor before starting {task_id}"] + blockers
236
- if blockers:
237
- raise WorkflowError("start_blocked", task_id, tuple(blockers))
238
-
239
- context_path = directory / "spec-context.yml"
240
- refreshed = refresh_context(load_context(str(context_path)), root, artifact_root=str(directory))
241
- save_context(str(context_path), refreshed.context)
242
- # No marker exists at this point (active_exists raised above), so no
243
- # re-sync is needed before activation below.
244
- current_hash = context_sha256(refreshed.context)
245
- owned = tuple(item for item in owned_paths if isinstance(item, str))
246
- _validate_start_manifest(directory, candidate)
247
- output = _write_projection(root, candidate, refreshed.context, task_id, session_output)
248
- try:
249
- active = replace(_new_active(root, str(directory), task_id, current_hash, owned), status="active")
250
- markdown = _commit_state(root, str(candidate), task_id, "in-progress", None, active, f"- [{_today()}] started")
251
- except Exception as exc:
252
- code = getattr(exc, "code", "activation_failed") if isinstance(exc, ValueError) else "activation_failed"
253
- raise WorkflowError(str(code), str(exc)) from exc
254
- from cf_spec_context import _active_data
255
-
256
- return {
257
- "ok": True,
258
- "context_sha256": current_hash,
259
- "refresh_changes": [item.__dict__ for item in refreshed.changes],
260
- "active": _active_data(active),
261
- "session_output": str(output),
262
- "acceptance_manifest": str(directory / ".acceptance-manifest.json") if (directory / ".acceptance-manifest.json").is_file() else None,
263
- "markdown": markdown,
264
- }
265
-
266
-
267
- def _require_marker_task(root: str, task_id: str, task_dir: str, task_file: str) -> None:
268
- from cf_spec_context import ContextError, load_active_task
269
-
270
- recover_transition(root)
271
- try:
272
- active = load_active_task(root)
273
- except (ContextError, OSError, ValueError) as exc:
274
- raise WorkflowError("no_active_task", str(exc)) from exc
275
- directory, candidate = _resolve_paths(root, task_dir, task_file)
276
- if active.task_id != task_id or (Path(root) / active.task_dir).resolve() != directory.resolve():
277
- raise WorkflowError("active_mismatch", f"marker={active.task_id} requested={task_id}")
278
- located = locate_task_file(str(directory), task_id)
279
- if located is None or located.resolve() != candidate.resolve():
280
- raise WorkflowError("active_mismatch", "TASK file must be unique and match the active demand")
281
-
282
-
283
- def block_task(root: str, task_dir: str, task_file: str, task_id: str, reason: str) -> dict[str, object]:
284
- """Block a TASK: marker (when it is ours and active) plus Markdown view."""
285
- from cf_spec_context import ContextError, load_active_task
286
-
287
- if not reason.strip():
288
- raise WorkflowError("block_reason_required", task_id)
289
- recover_transition(root)
290
- _, target = _resolve_paths(root, task_dir, task_file)
291
- task_file = str(target)
292
- section = read_task_section(task_file, task_id)
293
- if section_status(section) in FINISHED_STATUSES:
294
- raise WorkflowError("task_already_done", task_id, ("completed TASKs cannot be blocked",))
295
- marker = "skipped_no_marker"
296
- active = None
297
- updated = None
298
- try:
299
- active = load_active_task(root)
300
- _require_marker_task(root, task_id, task_dir, task_file)
301
- if active.task_id == task_id and active.status == "active":
302
- updated = replace(active, status="blocked")
303
- marker = "blocked"
304
- elif active.task_id == task_id:
305
- marker = active.status
306
- updated = active
307
- except ContextError:
308
- if (Path(root) / ".code-flow/.active-task.json").exists():
309
- raise
310
- marker = "skipped_no_marker"
311
- today = _today()
312
- markdown = _commit_state(
313
- root, task_file, task_id, "blocked", active, updated,
314
- f"- [{today}] blocked ({reason})",
315
- prepend=f"> BLOCKED: {reason}",
316
- )
317
- return {"ok": True, "task_id": task_id, "marker": marker, "markdown": markdown}
318
-
319
-
320
- def resume_task(root: str, task_dir: str, task_file: str, task_id: str) -> dict[str, object]:
321
- """Resume a blocked/paused TASK after its blockers clear."""
322
- from cf_spec_context import load_active_task
323
-
324
- recover_transition(root)
325
- _, target = _resolve_paths(root, task_dir, task_file)
326
- task_file = str(target)
327
- active = None
328
- if (Path(root) / ".code-flow/.active-task.json").exists():
329
- _require_marker_task(root, task_id, task_dir, task_file)
330
- active = load_active_task(root)
331
- if active.status not in ("blocked", "paused"):
332
- raise WorkflowError("resume_unexpected_state", active.status, (task_id,))
333
- section = read_task_section(task_file, task_id)
334
- if active is None and section_status(section) != "blocked":
335
- raise WorkflowError("resume_unexpected_state", section_status(section), (task_id,))
336
- notes = section_notes(section)
337
- statuses = _all_statuses(task_file)
338
- unmet = [dep for dep in section_depends(section) if statuses.get(dep) not in FINISHED_STATUSES]
339
- if notes or unmet:
340
- raise WorkflowError(
341
- "resume_blocked", task_id,
342
- tuple(([f"{len(notes)} unresolved #NOTES"] if notes else []) + ([f"unmet depends: {', '.join(unmet)}"] if unmet else [])),
343
- )
344
- resumed = replace(active, status="active") if active is not None else None
345
- status = "in-progress" if resumed is not None else "draft"
346
- markdown = _commit_state(root, task_file, task_id, status, active, resumed, f"- [{_today()}] resumed ({status})")
347
- return {"ok": True, "task_id": task_id, "marker": resumed.status if resumed else "absent", "markdown": markdown}
348
-
349
-
350
- def complete_task(root: str, task_dir: str, task_file: str, task_id: str, gate_passed: bool) -> dict[str, object]:
351
- """Complete a TASK after its Done Gate passed; syncs both records."""
352
- from cf_spec_context import load_active_task
353
-
354
- if not gate_passed:
355
- raise WorkflowError("gate_failed", task_id, ("Done Gate 未通过",))
356
- _require_marker_task(root, task_id, task_dir, task_file)
357
- _, target = _resolve_paths(root, task_dir, task_file)
358
- task_file = str(target)
359
- active = load_active_task(root)
360
- if active.status != "active":
361
- raise WorkflowError("invalid_active_transition", active.status)
362
- completed = replace(active, status="completed")
363
- from cf_spec_context import _active_data
364
-
365
- markdown = _commit_state(root, task_file, task_id, "done", active, None, f"- [{_today()}] completed (done)")
366
- return {"ok": True, "task_id": task_id, "active": _active_data(completed), "markdown": markdown}