@michaelxwb/code-flow 0.6.5 → 1.0.0

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 (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,178 +0,0 @@
1
- #!/usr/bin/env python3
2
- """UserPromptSubmit hook for the Context-first three-branch router."""
3
-
4
- import json
5
- import os
6
- import re
7
- import sys
8
-
9
- import cf_log
10
- from cf_checks import detect_correction
11
- from cf_core import (
12
- _log,
13
- ensure_utf8_io,
14
- load_config,
15
- normalize_path,
16
- resolve_enforcement,
17
- resolve_quality_loop,
18
- resolve_session_id,
19
- timing_log,
20
- )
21
- from cf_session_state import load_session_state, save_session_state
22
- from cf_spec_context import injection_version
23
- from cf_spec_router import RouterError, route_prompt
24
-
25
-
26
- _PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,6})(?![a-zA-Z0-9_])')
27
- _EXT_RE = re.compile(r'\.(py|js|ts|go|rs|java|rb|cs|cpp|c|h)$')
28
-
29
- _COMPRESS_INTERVAL = 25
30
- _REINJECT_INTERVAL = 25
31
- _REMINDER_TEXT = (
32
- "⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
33
- )
34
- _WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
35
-
36
-
37
- def extract_paths_from_prompt(prompt: str) -> list[str]:
38
- """Extract explicit file paths without semantic keyword inference."""
39
- paths = []
40
- seen = set()
41
- for match in _PATH_RE.finditer(prompt):
42
- candidate = normalize_path(match.group(1).lstrip('@`'))
43
- if candidate not in seen and ('/' in candidate or _EXT_RE.search(candidate)):
44
- paths.append(candidate)
45
- seen.add(candidate)
46
- return paths
47
-
48
-
49
- def _payload(text: str, mode: str) -> dict[str, object]:
50
- payload: dict[str, object] = {
51
- "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": text}
52
- }
53
- if os.environ.get("CF_DEBUG") == "1":
54
- payload["debug"] = {"mode": mode}
55
- return payload
56
-
57
-
58
- def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> None:
59
- config = load_config(root)
60
- if not config or not resolve_quality_loop(config)["correction_capture"]:
61
- return
62
- correction = detect_correction(prompt)
63
- if correction:
64
- cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
65
-
66
-
67
- def _session_reminder(root: str, sid: str) -> str:
68
- """One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
69
-
70
- Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
71
- corrupt state file degrades to no reminder rather than breaking the hook.
72
- """
73
- config = load_config(root)
74
- if not config or not resolve_quality_loop(config)["compress_reminder"]:
75
- return ""
76
- state = load_session_state(root)
77
- try:
78
- if state.get("session_id") != sid:
79
- state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
80
- state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
81
- reminder = ""
82
- if state["prompt_count"] >= int(state.get("next_remind_at", _COMPRESS_INTERVAL)):
83
- reminder = _REMINDER_TEXT
84
- state["next_remind_at"] = int(state["next_remind_at"]) + _COMPRESS_INTERVAL
85
- except (ValueError, TypeError):
86
- return ""
87
- save_session_state(root, state)
88
- return reminder
89
-
90
-
91
- def _route_with_enforcement(root: str, paths: list[str], sid: str, enforcement: str) -> tuple[object, object]:
92
- """Route with enforcement-aware failure handling.
93
-
94
- required — fail closed with the SPEC_WORKFLOW_BLOCKED message;
95
- warn — degrade to a short advisory message;
96
- inject — degrade silently.
97
- Returns (result, error_payload); exactly one is not None.
98
- """
99
- try:
100
- return route_prompt(root, paths, sid), None
101
- except RouterError as exc:
102
- if enforcement == "required":
103
- message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
104
- return None, _payload(message, "blocked")
105
- if enforcement == "warn":
106
- return None, _payload(_WARN_TEXT.format(message=str(exc)), "warn")
107
- return None, None
108
-
109
-
110
- def main() -> None:
111
- try:
112
- ensure_utf8_io()
113
- raw = sys.stdin.read()
114
- if not raw.strip():
115
- return
116
- data = json.loads(raw)
117
- prompt = data.get("prompt", "")
118
- if not isinstance(prompt, str) or not prompt.strip():
119
- return
120
- root = os.getcwd()
121
- sid = resolve_session_id(data)
122
- config = load_config(root)
123
- enforcement = resolve_enforcement(config) if config else "required"
124
- paths = extract_paths_from_prompt(prompt)
125
- _record_correction(root, prompt, paths, sid)
126
- result, error_payload = _route_with_enforcement(root, paths, sid, enforcement)
127
- if error_payload is not None:
128
- sys.stdout.write(json.dumps(error_payload, ensure_ascii=False))
129
- return
130
- if result is None:
131
- return
132
- reminder = ""
133
- if config and resolve_quality_loop(config)["compress_reminder"]:
134
- reminder = _session_reminder(root, sid)
135
- text = result.text
136
- if result.mode == "task" and result.context_sha256:
137
- # Dedup: inject once per session/task/contract version, refreshing
138
- # at the compress cadence so long sessions do not re-pay 12k chars
139
- # on every prompt. The key covers task_id and the TASK contract
140
- # digest (not just the Context hash) so switching TASKs on a
141
- # shared Context always re-injects the new contract.
142
- state = load_session_state(root)
143
- count = int(state.get("prompt_count", 0))
144
- dirty = False
145
- if not (config and resolve_quality_loop(config)["compress_reminder"]):
146
- # compress_reminder off → _session_reminder never bumps the
147
- # counter; maintain the cadence here so the periodic refresh
148
- # still fires after compaction.
149
- count += 1
150
- state["prompt_count"] = count
151
- dirty = True
152
- version = injection_version(root, sid, result.context_sha256)
153
- injected = state.get("injected_version")
154
- last_inject = int(state.get("last_inject_prompt", 0))
155
- if injected == version and count - last_inject < _REINJECT_INTERVAL:
156
- text = ""
157
- else:
158
- state["injected_version"] = version
159
- state["last_inject_prompt"] = count
160
- dirty = True
161
- if dirty:
162
- save_session_state(root, state)
163
- if not text and not reminder:
164
- return
165
- if text:
166
- cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
167
- if text and reminder:
168
- text = text.rstrip() + "\n\n" + reminder
169
- elif reminder:
170
- text = reminder
171
- sys.stdout.write(json.dumps(_payload(text, result.mode if text else "reminder"), ensure_ascii=False))
172
- except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
173
- _log(f"cf_user_prompt_hook error: {exc}")
174
-
175
-
176
- if __name__ == "__main__":
177
- main()
178
- timing_log("cf_user_prompt_hook")
@@ -1,81 +0,0 @@
1
- """Programmatic validation entry with complete Git scope and argv execution."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import math
7
- import os
8
- from pathlib import Path
9
- from typing import IO, Optional, Sequence
10
- import sys
11
-
12
- from cf_exec_base import execution_session
13
- from cf_spec_context import _git_changes, current_owned_paths, load_active_task
14
-
15
-
16
- def validation_scope(root: str, files: Sequence[str] = ()) -> tuple[str, ...]:
17
- base = Path(root).resolve()
18
- if files:
19
- selected = []
20
- for name in files:
21
- path = (base / name).resolve()
22
- if base not in path.parents:
23
- raise ValueError(f"validation path outside project: {name}")
24
- selected.append(path.relative_to(base).as_posix())
25
- return tuple(sorted(set(selected)))
26
- if (base / ".code-flow/.active-task.json").exists():
27
- return current_owned_paths(str(base), load_active_task(str(base)))
28
- return tuple(sorted(_git_changes(str(base))))
29
-
30
-
31
- def _validators(root: str) -> list[dict[str, object]]:
32
- from cf_stop_hook import load_validators
33
- if (Path(root) / ".code-flow/validation.yml").is_file():
34
- return load_validators(root)
35
- package = Path(root) / "package.json"
36
- if not package.is_file():
37
- return []
38
- data = json.loads(package.read_text(encoding="utf-8"))
39
- scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
40
- if not isinstance(scripts, dict):
41
- raise ValueError("invalid package scripts")
42
- return [{"name": name, "trigger": "*", "command": f"npm run {name}", "timeout": 60000}
43
- for name in ("test", "lint") if isinstance(scripts.get(name), str)]
44
-
45
-
46
- def validate_files(root: str, files: Sequence[str] = (), budget: Optional[float] = None) -> dict[str, object]:
47
- from cf_stop_hook import run_validators
48
- selected = validation_scope(root, files)
49
- validators = _validators(root)
50
- if not selected:
51
- return {"decision": "pass", "files": [], "reason": "no_changes"}
52
- if not validators:
53
- return {"decision": "block", "files": selected, "reason": "no_validators_configured"}
54
- maximum = budget if budget is not None else sum(float(v.get("timeout", 30000)) / 1000 for v in validators)
55
- if not math.isfinite(maximum) or maximum <= 0:
56
- raise ValueError("validation budget must be finite and positive")
57
- with execution_session():
58
- failures, truncated = run_validators(root, validators, list(selected), "cf-validate", maximum, strict=True)
59
- return {"decision": "block" if failures or truncated else "pass", "files": selected,
60
- "failures": failures, "incomplete": truncated}
61
-
62
-
63
- def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
64
- parser = argparse.ArgumentParser()
65
- parser.add_argument("--root", default=os.getcwd())
66
- parser.add_argument("--files", nargs="*", default=[])
67
- parser.add_argument("--budget", type=float)
68
- parser.add_argument("--scope-only", action="store_true")
69
- parser.add_argument("--json", action="store_true")
70
- args = parser.parse_args(argv)
71
- try:
72
- result = {"files": validation_scope(args.root, args.files)} if args.scope_only else validate_files(args.root, args.files, args.budget)
73
- stdout.write(json.dumps(result, ensure_ascii=False))
74
- return 3 if result.get("decision") == "block" else 0
75
- except (OSError, ValueError) as exc:
76
- stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
77
- return 2
78
-
79
-
80
- if __name__ == "__main__":
81
- raise SystemExit(main())
@@ -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}