@michaelxwb/code-flow 0.6.2 → 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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  3. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  4. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  5. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  6. package/src/adapters/claude/commands/cf-task/start.md +18 -17
  7. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  8. package/src/adapters/claude/commands/cf-validate.md +12 -13
  9. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  10. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  11. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  12. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  13. package/src/adapters/codex/skills/cf-task-start/SKILL.md +18 -17
  14. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  15. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  16. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  17. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  18. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  19. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  20. package/src/adapters/costrict/commands/cf-task/start.md +18 -17
  21. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  22. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  23. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  24. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  25. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  26. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  27. package/src/adapters/opencode/commands/cf-task/start.md +18 -17
  28. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  29. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  30. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  31. package/src/cli.js +177 -63
  32. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  33. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +85 -36
  34. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  35. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  36. package/src/core/code-flow/scripts/cf_core.py +6 -0
  37. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  38. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +8 -4
  39. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  40. package/src/core/code-flow/scripts/cf_spec_context.py +178 -54
  41. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  42. package/src/core/code-flow/scripts/cf_spec_router.py +2 -0
  43. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  44. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  45. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  46. package/src/core/code-flow/scripts/cf_stop_hook.py +98 -59
  47. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  48. package/src/core/code-flow/scripts/cf_task_runtime.py +62 -27
  49. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  50. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  51. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  52. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  53. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  54. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  from dataclasses import dataclass, replace
7
7
  import hashlib
8
+ import json
8
9
  from pathlib import Path
9
10
  import time
10
11
  from typing import Mapping, Optional
@@ -29,6 +30,8 @@ from cf_spec_resolver import resolve_candidates, SpecCandidate
29
30
  from cf_spec_session import context_sha256
30
31
  from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
31
32
  from cf_core import phase_timing
33
+ from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
34
+ from cf_exec_base import execution_session
32
35
 
33
36
 
34
37
  @dataclass(frozen=True)
@@ -51,24 +54,45 @@ def _context_path(task_dir: str) -> str:
51
54
  return str(Path(task_dir) / "spec-context.yml")
52
55
 
53
56
 
54
- def _manual_manifest_issue(task_dir: str) -> str:
57
+ def _manual_manifest_issue(task_dir: str, owner: str = "") -> str:
55
58
  path = Path(task_dir) / ".acceptance-manifest.json"
56
59
  if not path.is_file():
57
60
  return ""
58
61
  try:
59
62
  import json
60
63
 
61
- data = json.loads(path.read_text(encoding="utf-8"))
64
+ data = load_manifest(path)
62
65
  pending = [
63
66
  item.get("id", "unknown")
64
67
  for item in data.get("scenarios", [])
65
- if isinstance(item, dict) and item.get("kind") == "manual" and item.get("status") != "verified"
68
+ if item.get("kind") == "manual" and not verified_evidence(item)
69
+ and (not owner or not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") == owner)
66
70
  ]
67
71
  return f"manual 场景未完成: {', '.join(pending)}" if pending else ""
68
72
  except (OSError, ValueError, TypeError):
69
73
  return "acceptance manifest invalid"
70
74
 
71
75
 
76
+ def _acceptance_baseline_issue(task_dir: str, owner: str) -> str:
77
+ from cf_workflow_service import locate_task_file
78
+ path = Path(task_dir) / ".acceptance-manifest.json"
79
+ task = locate_task_file(task_dir, owner)
80
+ try:
81
+ requires = task is not None and "## Acceptance Coverage" in task.read_text(encoding="utf-8")
82
+ if not path.is_file():
83
+ return "acceptance_manifest_missing" if requires else ""
84
+ data = load_manifest(path)
85
+ if requires:
86
+ from cf_acceptance_manifest import validate_manifest
87
+ valid, reason = validate_manifest(str(task), str(path))
88
+ if not valid:
89
+ return reason
90
+ validate_execution_baseline(path, data)
91
+ return ""
92
+ except (OSError, ValueError) as exc:
93
+ return f"acceptance_manifest_invalid: {exc}"
94
+
95
+
72
96
  def _required_candidate(candidate: SpecCandidate) -> bool:
73
97
  metadata = candidate.metadata
74
98
  return metadata.enforcement == "required" and any(
@@ -176,33 +200,38 @@ def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object
176
200
  }
177
201
 
178
202
 
179
- def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False) -> DoneResult:
203
+ def _run_acceptance(root: str, task_dir: str, owner: str, include_e2e: bool,
204
+ deadline: Optional[float]) -> str:
205
+ baseline_issue = _acceptance_baseline_issue(task_dir, owner)
206
+ if baseline_issue:
207
+ return baseline_issue
208
+ manual_issue = _manual_manifest_issue(task_dir, owner)
209
+ if manual_issue:
210
+ return manual_issue
211
+ manifest_path = Path(task_dir) / ".acceptance-manifest.json"
212
+ if manifest_path.is_file():
213
+ from cf_acceptance_runner import run_manifest
214
+ scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e, owner=owner, deadline=deadline)
215
+ if scenario_result["decision"] == "block":
216
+ reason = scenario_result.get("error") or "acceptance scenario failed"
217
+ return f"acceptance scenario failed: {reason}"
218
+ return ""
219
+
220
+
221
+ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
222
+ active = load_active_task(root)
223
+ owner = task_id or active.task_id
224
+ if owner != active.task_id or (Path(root) / active.task_dir).resolve() != Path(task_dir).resolve():
225
+ return DoneResult("block", (), (), "active_mismatch: task identity does not match marker")
180
226
  started = time.monotonic()
181
- phase_started = time.monotonic()
182
227
  scope_result = evaluate_scope(root, task_dir)
183
- phase_timing("done.evaluate_scope", phase_started)
228
+ phase_timing("done.evaluate_scope", started)
184
229
  if scope_result.decision == "pause":
185
230
  return DoneResult("block", scope_result.files, (), scope_result.message)
186
- manual_issue = _manual_manifest_issue(task_dir)
187
- if manual_issue:
188
- return DoneResult("block", scope_result.files, (), manual_issue)
189
- manifest_path = Path(task_dir) / ".acceptance-manifest.json"
190
- if manifest_path.is_file():
191
- try:
192
- import json
193
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
194
- has_commands = any(
195
- isinstance(item, dict) and isinstance(item.get("command"), list)
196
- for item in manifest.get("scenarios", [])
197
- )
198
- except (OSError, ValueError, TypeError):
199
- return DoneResult("block", scope_result.files, (), "acceptance manifest invalid")
200
- if has_commands:
201
- from cf_acceptance_runner import run_manifest
202
-
203
- scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e)
204
- if scenario_result["decision"] == "block":
205
- return DoneResult("block", scope_result.files, (), "acceptance scenario failed")
231
+ deadline = started + budget if budget is not None else None
232
+ issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline)
233
+ if issue:
234
+ return DoneResult("block", scope_result.files, (), issue)
206
235
  phase_started = time.monotonic()
207
236
  context = load_context(_context_path(task_dir))
208
237
  diff_hash = _diff_hash(root, scope_result.files)
@@ -227,4 +256,10 @@ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optiona
227
256
  save_context(_context_path(task_dir), updated)
228
257
  gate = validate_stage(updated, "code", diff_sha256=diff_hash)
229
258
  phase_timing("done.total", started)
230
- return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
259
+ return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
260
+ "; ".join(issue.message for issue in gate.errors))
261
+
262
+
263
+ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
264
+ with execution_session():
265
+ return _run_done_gate(root, task_dir, cheap, budget, include_e2e, task_id)
@@ -0,0 +1,3 @@
1
+ """Shared lifecycle predicates without loading the workflow runtime."""
2
+
3
+ FINISHED_STATUSES = ("done", "verified")
@@ -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())
@@ -19,6 +19,7 @@ from cf_core import (
19
19
  timing_log,
20
20
  )
21
21
  from cf_session_state import load_session_state, save_session_state
22
+ from cf_spec_context import injection_version
22
23
  from cf_spec_router import RouterError, route_prompt
23
24
 
24
25
 
@@ -133,9 +134,11 @@ def main() -> None:
133
134
  reminder = _session_reminder(root, sid)
134
135
  text = result.text
135
136
  if result.mode == "task" and result.context_sha256:
136
- # Dedup: inject the projection once per session/context, refreshing
137
+ # Dedup: inject once per session/task/contract version, refreshing
137
138
  # at the compress cadence so long sessions do not re-pay 12k chars
138
- # 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.
139
142
  state = load_session_state(root)
140
143
  count = int(state.get("prompt_count", 0))
141
144
  dirty = False
@@ -146,12 +149,13 @@ def main() -> None:
146
149
  count += 1
147
150
  state["prompt_count"] = count
148
151
  dirty = True
149
- injected = state.get("injected_sha256")
152
+ version = injection_version(root, sid, result.context_sha256)
153
+ injected = state.get("injected_version")
150
154
  last_inject = int(state.get("last_inject_prompt", 0))
151
- if injected == result.context_sha256 and count - last_inject < _REINJECT_INTERVAL:
155
+ if injected == version and count - last_inject < _REINJECT_INTERVAL:
152
156
  text = ""
153
157
  else:
154
- state["injected_sha256"] = result.context_sha256
158
+ state["injected_version"] = version
155
159
  state["last_inject_prompt"] = count
156
160
  dirty = True
157
161
  if dirty:
@@ -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())