@michaelxwb/code-flow 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-task/archive.md +5 -5
- package/src/adapters/claude/commands/cf-task/block.md +15 -9
- package/src/adapters/claude/commands/cf-task/graph.md +8 -1
- package/src/adapters/claude/commands/cf-task/note.md +1 -4
- package/src/adapters/claude/commands/cf-task/plan.md +40 -0
- package/src/adapters/claude/commands/cf-task/start.md +22 -22
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/claude/commands/cf-validate.md +12 -13
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
- package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
- package/src/adapters/costrict/commands/cf-task/block.md +15 -9
- package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
- package/src/adapters/costrict/commands/cf-task/note.md +1 -4
- package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
- package/src/adapters/costrict/commands/cf-task/start.md +22 -22
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/costrict/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
- package/src/adapters/opencode/commands/cf-task/block.md +15 -9
- package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
- package/src/adapters/opencode/commands/cf-task/note.md +1 -4
- package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
- package/src/adapters/opencode/commands/cf-task/start.md +22 -22
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
- package/src/adapters/opencode/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
- package/src/cli.js +177 -63
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +26 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
- package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
- package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
- package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
- package/src/core/code-flow/scripts/cf_task_state.py +3 -0
- package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
- package/src/core/code-flow/scripts/cf_validation.py +81 -0
- package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Executable task handoffs used by every platform command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Optional, Sequence
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from cf_acceptance_runner import run_manifest
|
|
13
|
+
from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
|
|
14
|
+
from cf_task_index import parse_task_file
|
|
15
|
+
from cf_task_runtime import run_done_gate
|
|
16
|
+
from cf_workflow_service import (FINISHED_STATUSES, _require_marker_task,
|
|
17
|
+
_sync_markdown, block_task, complete_task, locate_task_file, resume_task)
|
|
18
|
+
from cf_workflow_transaction import recover_transition
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
|
|
22
|
+
_require_marker_task(root, task_id, directory, task_file)
|
|
23
|
+
gate = run_done_gate(root, directory, task_id=task_id)
|
|
24
|
+
if gate.decision != "pass":
|
|
25
|
+
return {"decision": "block", "reason": gate.message, "evidence": gate.evidence}
|
|
26
|
+
return {"decision": "pass", **complete_task(root, directory, task_file, task_id, True)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _demand_tasks(directory: Path) -> list[tuple[Path, str, str]]:
|
|
30
|
+
tasks = []
|
|
31
|
+
seen = set()
|
|
32
|
+
for task_file in sorted(directory.glob("*.md")):
|
|
33
|
+
if task_file.name.endswith((".design.md", ".prd.md")):
|
|
34
|
+
continue
|
|
35
|
+
if not re.search(r"(?m)^## TASK-\d+:", task_file.read_text(encoding="utf-8")):
|
|
36
|
+
continue
|
|
37
|
+
for node in parse_task_file(str(task_file)):
|
|
38
|
+
if node.task_id in seen:
|
|
39
|
+
raise ValueError(f"duplicate task id: {node.task_id}")
|
|
40
|
+
seen.add(node.task_id)
|
|
41
|
+
tasks.append((task_file, node.task_id, node.status))
|
|
42
|
+
if not tasks:
|
|
43
|
+
raise ValueError("no TASK sections found")
|
|
44
|
+
return tasks
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_e2e(root: str, directory: str) -> dict[str, object]:
|
|
48
|
+
recover_transition(root)
|
|
49
|
+
tasks = _demand_tasks(Path(directory))
|
|
50
|
+
unfinished = [task_id for _, task_id, status in tasks if status not in FINISHED_STATUSES]
|
|
51
|
+
if unfinished:
|
|
52
|
+
return {"decision": "block", "reason": "unfinished_tasks", "tasks": unfinished}
|
|
53
|
+
if (Path(root) / ".code-flow/.active-task.json").exists():
|
|
54
|
+
return {"decision": "block", "reason": "finish_active_task_first"}
|
|
55
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
56
|
+
data = load_manifest(manifest)
|
|
57
|
+
validate_execution_baseline(manifest, data)
|
|
58
|
+
bound_task = (manifest.parent / str(data.get("task_file", ""))).resolve()
|
|
59
|
+
if {task.resolve() for task, _, _ in tasks} != {bound_task}:
|
|
60
|
+
return {"decision": "block", "reason": "acceptance_manifest_task_mismatch"}
|
|
61
|
+
required = [row["id"] for row in data["scenarios"] if row.get("kind", "functional") != "e2e"
|
|
62
|
+
and not verified_evidence(row)]
|
|
63
|
+
if required:
|
|
64
|
+
return {"decision": "block", "reason": "functional_or_manual_evidence_missing", "scenarios": required}
|
|
65
|
+
result = run_manifest(str(manifest), root, write_evidence=True, only_e2e=True)
|
|
66
|
+
if result["decision"] == "pass":
|
|
67
|
+
for task_file, task_id, _ in tasks:
|
|
68
|
+
_sync_markdown(str(task_file), task_id, "verified")
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
73
|
+
parser = argparse.ArgumentParser()
|
|
74
|
+
parser.add_argument("action", choices=("finish", "block", "resume", "verify-e2e"))
|
|
75
|
+
parser.add_argument("--root", default=os.getcwd())
|
|
76
|
+
parser.add_argument("--task-dir", required=True)
|
|
77
|
+
parser.add_argument("--task", default="")
|
|
78
|
+
parser.add_argument("--reason", default="")
|
|
79
|
+
parser.add_argument("--json", action="store_true")
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
try:
|
|
82
|
+
root = str(Path(args.root).resolve())
|
|
83
|
+
directory = str((Path(root) / args.task_dir).resolve())
|
|
84
|
+
if Path(root) not in Path(directory).parents:
|
|
85
|
+
raise ValueError("task directory outside project")
|
|
86
|
+
recover_transition(root)
|
|
87
|
+
if args.action == "verify-e2e":
|
|
88
|
+
result = verify_e2e(root, directory)
|
|
89
|
+
else:
|
|
90
|
+
task = locate_task_file(directory, args.task)
|
|
91
|
+
if task is None:
|
|
92
|
+
raise ValueError("task file missing or ambiguous")
|
|
93
|
+
if args.action == "finish":
|
|
94
|
+
result = finish_task(root, directory, str(task), args.task)
|
|
95
|
+
elif args.action == "block":
|
|
96
|
+
result = block_task(root, directory, str(task), args.task, args.reason)
|
|
97
|
+
else:
|
|
98
|
+
result = resume_task(root, directory, str(task), args.task)
|
|
99
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
100
|
+
return 3 if result.get("decision") == "block" else 0
|
|
101
|
+
except (OSError, ValueError) as exc:
|
|
102
|
+
stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -16,8 +16,10 @@ from cf_core import (
|
|
|
16
16
|
resolve_enforcement,
|
|
17
17
|
resolve_quality_loop,
|
|
18
18
|
resolve_session_id,
|
|
19
|
+
timing_log,
|
|
19
20
|
)
|
|
20
21
|
from cf_session_state import load_session_state, save_session_state
|
|
22
|
+
from cf_spec_context import injection_version
|
|
21
23
|
from cf_spec_router import RouterError, route_prompt
|
|
22
24
|
|
|
23
25
|
|
|
@@ -132,9 +134,11 @@ def main() -> None:
|
|
|
132
134
|
reminder = _session_reminder(root, sid)
|
|
133
135
|
text = result.text
|
|
134
136
|
if result.mode == "task" and result.context_sha256:
|
|
135
|
-
# Dedup: inject
|
|
137
|
+
# Dedup: inject once per session/task/contract version, refreshing
|
|
136
138
|
# at the compress cadence so long sessions do not re-pay 12k chars
|
|
137
|
-
# on every prompt.
|
|
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.
|
|
138
142
|
state = load_session_state(root)
|
|
139
143
|
count = int(state.get("prompt_count", 0))
|
|
140
144
|
dirty = False
|
|
@@ -145,12 +149,13 @@ def main() -> None:
|
|
|
145
149
|
count += 1
|
|
146
150
|
state["prompt_count"] = count
|
|
147
151
|
dirty = True
|
|
148
|
-
|
|
152
|
+
version = injection_version(root, sid, result.context_sha256)
|
|
153
|
+
injected = state.get("injected_version")
|
|
149
154
|
last_inject = int(state.get("last_inject_prompt", 0))
|
|
150
|
-
if injected ==
|
|
155
|
+
if injected == version and count - last_inject < _REINJECT_INTERVAL:
|
|
151
156
|
text = ""
|
|
152
157
|
else:
|
|
153
|
-
state["
|
|
158
|
+
state["injected_version"] = version
|
|
154
159
|
state["last_inject_prompt"] = count
|
|
155
160
|
dirty = True
|
|
156
161
|
if dirty:
|
|
@@ -170,3 +175,4 @@ def main() -> None:
|
|
|
170
175
|
|
|
171
176
|
if __name__ == "__main__":
|
|
172
177
|
main()
|
|
178
|
+
timing_log("cf_user_prompt_hook")
|
|
@@ -0,0 +1,81 @@
|
|
|
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())
|
|
@@ -0,0 +1,366 @@
|
|
|
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}
|