@michaelxwb/code-flow 0.6.2 → 0.6.4

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 (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/adapters/claude/commands/cf-init.md +1 -0
  4. package/src/adapters/claude/commands/cf-spec.md +7 -3
  5. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  6. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  7. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  8. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  9. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  10. package/src/adapters/claude/commands/cf-task/start.md +96 -25
  11. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  12. package/src/adapters/claude/commands/cf-validate.md +15 -13
  13. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  14. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  15. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  16. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  17. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  18. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  19. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
  21. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  22. package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
  23. package/src/adapters/costrict/commands/cf-init.md +1 -0
  24. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  25. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  27. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  29. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  30. package/src/adapters/costrict/commands/cf-task/start.md +96 -25
  31. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  32. package/src/adapters/costrict/commands/cf-validate.md +15 -13
  33. package/src/adapters/opencode/commands/cf-init.md +9 -4
  34. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  35. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  36. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  37. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  38. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  39. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  40. package/src/adapters/opencode/commands/cf-task/start.md +96 -25
  41. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  42. package/src/adapters/opencode/commands/cf-validate.md +15 -13
  43. package/src/adapters/opencode/opencode.json +1 -2
  44. package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
  45. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  46. package/src/cli.js +228 -64
  47. package/src/core/code-flow/config.yml +2 -1
  48. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  49. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
  50. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  51. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  52. package/src/core/code-flow/scripts/cf_core.py +34 -1
  53. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  54. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  55. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
  56. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  57. package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
  58. package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
  59. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  60. package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
  61. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  62. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  63. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  64. package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
  65. package/src/core/code-flow/scripts/cf_task_index.py +148 -0
  66. package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
  67. package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
  68. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  69. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  70. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  71. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  72. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  73. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
  74. package/src/core/code-flow/validation.yml +5 -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
@@ -28,7 +29,9 @@ from cf_spec_metadata import load_spec_metadata
28
29
  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
- from cf_core import phase_timing
32
+ from cf_core import load_config, phase_timing, resolve_quality_loop
33
+ from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
34
+ from cf_exec_base import execution_session
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,68 @@ 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], cheap: bool = False) -> 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
+ if cheap:
212
+ # 轻量门禁:只做 manifest 基线与 manual 检查,不执行 functional 场景;
213
+ # 场景命令由 finish 的全量 Done Gate 执行。
214
+ return ""
215
+ manifest_path = Path(task_dir) / ".acceptance-manifest.json"
216
+ if manifest_path.is_file():
217
+ from cf_acceptance_runner import run_manifest
218
+ scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e, owner=owner, deadline=deadline)
219
+ if scenario_result["decision"] == "block":
220
+ reason = scenario_result.get("error") or "acceptance scenario failed"
221
+ return f"acceptance scenario failed: {reason}"
222
+ return ""
223
+
224
+
225
+ def _run_finish_validation(root: str, files: tuple[str, ...]) -> str:
226
+ """Full validation.yml (including heavy validators) once per TASK finish.
227
+
228
+ Stop-time checks stay cheap; this is the single automatic execution point
229
+ for slow suites (task finish `/cf-validate` scope). `quality_loop.finish_check:
230
+ false` disables it; projects without validation.yml / package.json scripts are a no-op.
231
+ """
232
+ if not resolve_quality_loop(load_config(root)).get("finish_check"):
233
+ return ""
234
+ from cf_validation import validate_files
235
+
236
+ try:
237
+ result = validate_files(root, files)
238
+ except (OSError, ValueError) as exc:
239
+ return f"finish validation error: {exc}"
240
+ reason = result.get("reason")
241
+ if result.get("decision") == "pass" or reason in ("no_changes", "no_validators_configured"):
242
+ return ""
243
+ failures = result.get("failures")
244
+ names = ", ".join(str(item.get("name", "validator")) for item in failures[:3]) if isinstance(failures, list) and failures else "validator"
245
+ if result.get("incomplete") and not failures:
246
+ return "finish validation 预算耗尽:部分 validator 未执行"
247
+ suffix = "(预算耗尽,部分未执行)" if result.get("incomplete") else ""
248
+ return f"finish validation failed{suffix}: {names}"
249
+
250
+
251
+ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
252
+ active = load_active_task(root)
253
+ owner = task_id or active.task_id
254
+ if owner != active.task_id or (Path(root) / active.task_dir).resolve() != Path(task_dir).resolve():
255
+ return DoneResult("block", (), (), "active_mismatch: task identity does not match marker")
180
256
  started = time.monotonic()
181
- phase_started = time.monotonic()
182
257
  scope_result = evaluate_scope(root, task_dir)
183
- phase_timing("done.evaluate_scope", phase_started)
258
+ phase_timing("done.evaluate_scope", started)
184
259
  if scope_result.decision == "pause":
185
260
  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")
261
+ deadline = started + budget if budget is not None else None
262
+ issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline, cheap)
263
+ if issue:
264
+ return DoneResult("block", scope_result.files, (), issue)
206
265
  phase_started = time.monotonic()
207
266
  context = load_context(_context_path(task_dir))
208
267
  diff_hash = _diff_hash(root, scope_result.files)
@@ -225,6 +284,16 @@ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optiona
225
284
  updated = _apply_evidence(context, tuple(all_evidence))
226
285
  if updated != context:
227
286
  save_context(_context_path(task_dir), updated)
228
- gate = validate_stage(updated, "code", diff_sha256=diff_hash)
287
+ gate = validate_stage(updated, "code", diff_sha256=diff_hash, allow_cheap_skips=cheap)
288
+ if gate.decision == "pass" and not cheap:
289
+ validation_issue = _run_finish_validation(root, scope_result.files)
290
+ if validation_issue:
291
+ return DoneResult("block", scope_result.files, tuple(all_evidence), validation_issue)
229
292
  phase_timing("done.total", started)
230
- return DoneResult(gate.decision, scope_result.files, tuple(all_evidence))
293
+ return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
294
+ "; ".join(issue.message for issue in gate.errors))
295
+
296
+
297
+ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
298
+ with execution_session():
299
+ 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())