@michaelxwb/code-flow 0.6.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -42
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,98 @@
1
+ """OpenCode v2 native event RPC; no Claude/Codex hook wire translation."""
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ from codeflow.core import cf_log
7
+ from codeflow.core.cf_core import ensure_utf8_io
8
+ from codeflow.spec.cf_edit_service import after_edit, prepare_edits
9
+ from codeflow.spec.cf_injection import injection_output
10
+ from codeflow.core.cf_exec_base import execution_session
11
+ from codeflow.core.cf_native_paths import project_root, validated_paths
12
+ from codeflow.core.cf_patch import FileOperation, parse_patch
13
+ from codeflow.core.cf_runtime_install import verify_install
14
+ from codeflow.hooks.cf_stop_hook import build_stop_result
15
+ from codeflow.hooks.cf_user_prompt_hook import build_prompt_context
16
+
17
+
18
+ def edit_paths(root: str, directory: str, event: dict[str, object]) -> tuple[tuple[str, bool], ...]:
19
+ tool, inputs = event.get("tool"), event.get("input")
20
+ if not isinstance(inputs, dict):
21
+ raise ValueError("native tool input must be an object")
22
+ if tool in {"edit", "write"}:
23
+ path = inputs.get("path")
24
+ if not isinstance(path, str) or not path or "\x00" in path:
25
+ raise ValueError(f"{tool}.input.path must be a nonempty path")
26
+ operations = (FileOperation("update", path),)
27
+ elif tool == "patch":
28
+ command = inputs.get("patchText")
29
+ if not isinstance(command, str):
30
+ raise ValueError("patch.input.patchText must be a string")
31
+ operations = parse_patch(command)
32
+ else:
33
+ return ()
34
+ return validated_paths(root, directory, operations)
35
+
36
+
37
+ def tool_result(root: str, directory: str, kind: str, event: dict[str, object], sid: str) -> dict[str, object]:
38
+ tool = event.get("tool")
39
+ if tool not in {"edit", "write", "patch"}:
40
+ return {"context": "", "blocked": False}
41
+ if kind == "tool.after" and event.get("status") != "completed":
42
+ cf_log.degrade(root, "opencode_tool", f"{tool}: edit not confirmed (status={event.get('status')})", sid)
43
+ return {"context": "", "blocked": False}
44
+ paths = edit_paths(root, directory, event)
45
+ if kind == "tool.before":
46
+ context, blocked = prepare_edits(root, tuple(path for path, _ in paths), sid, str(tool))
47
+ return {"context": context, "blocked": blocked}
48
+ text = []
49
+ blocked = False
50
+ for path, deleted in paths:
51
+ feedback = after_edit(root, path, sid, str(tool), deleted)
52
+ if feedback:
53
+ text.append(feedback)
54
+ context = "\n\n".join(dict.fromkeys(text))
55
+ return {"context": context, "blocked": blocked}
56
+
57
+
58
+ def dispatch(data: dict[str, object]) -> dict[str, object]:
59
+ kind, directory, event = data.get("kind"), data.get("directory"), data.get("event")
60
+ if not isinstance(directory, str) or not os.path.isabs(directory) or not isinstance(event, dict):
61
+ raise ValueError("native event requires absolute session directory and an event object")
62
+ sid = event.get("sessionID")
63
+ if not isinstance(sid, str) or not sid:
64
+ raise ValueError("native event requires sessionID")
65
+ root = project_root(directory)
66
+ if not root:
67
+ return {"context": "", "blocked": False}
68
+ verify_install(root)
69
+ if kind == "session.prompt":
70
+ prompt = event.get("prompt")
71
+ if not isinstance(prompt, dict) or not isinstance(prompt.get("text"), str):
72
+ raise ValueError("native prompt.text must be a string")
73
+ text, mode = build_prompt_context(prompt["text"], sid, root)
74
+ return {"context": text, "blocked": mode == "blocked"}
75
+ if kind in {"tool.before", "tool.after"}:
76
+ return tool_result(root, directory, kind, event, sid)
77
+ if kind == "session.idle":
78
+ with execution_session():
79
+ result = build_stop_result(root, sid)
80
+ return {"context": result.get("reason", ""), "blocked": result.get("decision") == "block"}
81
+ raise ValueError(f"unknown native event kind: {kind}")
82
+
83
+
84
+ def main() -> None:
85
+ ensure_utf8_io()
86
+ try:
87
+ data = json.loads(sys.stdin.read())
88
+ if not isinstance(data, dict):
89
+ raise ValueError("native RPC input must be an object")
90
+ with injection_output():
91
+ result = dispatch(data)
92
+ sys.stdout.write(json.dumps(result, ensure_ascii=False))
93
+ sys.stdout.flush()
94
+ return
95
+ except Exception as exc:
96
+ cf_log.warn(f"cf_opencode_event error: {exc}")
97
+ result = {"error": f"code-flow OpenCode runtime failed: {exc}. Run code-flow migrate --runtime --dry-run, then --apply."}
98
+ sys.stdout.write(json.dumps(result, ensure_ascii=False))
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python3
2
+ """Claude-style PostToolUse protocol entry; edit rules live in cf_edit_service."""
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ from codeflow.core.cf_core import ensure_utf8_io, resolve_session_id, timing_log
8
+ from codeflow.core.cf_log import warn
9
+ from codeflow.spec.cf_edit_service import after_edit
10
+
11
+
12
+ def main() -> None:
13
+ try:
14
+ ensure_utf8_io()
15
+ if sys.stdin.isatty():
16
+ warn("cf_post_hook.py: missing stdin event")
17
+ return
18
+ raw = sys.stdin.read()
19
+ if not raw.strip():
20
+ return
21
+ data = json.loads(raw)
22
+ tool = data.get("tool_name", "")
23
+ path = (data.get("tool_input") or {}).get("file_path", "")
24
+ if tool not in {"Edit", "Write", "MultiEdit"} or not isinstance(path, str) or not path:
25
+ return
26
+ text = after_edit(os.getcwd(), path, resolve_session_id(data), tool)
27
+ if text:
28
+ sys.stdout.write(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": text}}, ensure_ascii=False))
29
+ except Exception as exc:
30
+ warn(f"cf_post_hook.py error: {exc}")
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ """Claude-style PreToolUse protocol entry; edit rules live in cf_edit_service."""
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ from codeflow.core.cf_core import ensure_utf8_io, resolve_session_id, timing_log
8
+ from codeflow.core.cf_log import warn
9
+ from codeflow.spec.cf_edit_service import prepare_edit
10
+ from codeflow.spec.cf_injection import injection_output, pre_tool_payload
11
+ from codeflow.core.cf_native_paths import project_root
12
+
13
+
14
+ def main() -> None:
15
+ try:
16
+ ensure_utf8_io()
17
+ if sys.stdin.isatty():
18
+ warn("cf_pre_tool_hook.py: missing stdin event")
19
+ return
20
+ raw = sys.stdin.read()
21
+ if not raw.strip():
22
+ return
23
+ data = json.loads(raw)
24
+ tool = data.get("tool_name", "")
25
+ path = (data.get("tool_input") or {}).get("file_path", "")
26
+ if tool not in {"Edit", "Write", "MultiEdit"} or not isinstance(path, str) or not path:
27
+ return
28
+ directory = os.getcwd()
29
+ root = project_root(directory)
30
+ if not root:
31
+ return
32
+ absolute = path if os.path.isabs(path) else os.path.join(directory, path)
33
+ with injection_output():
34
+ text, blocked = prepare_edit(root, absolute, resolve_session_id(data), tool)
35
+ if text:
36
+ sys.stdout.write(json.dumps(pre_tool_payload(text, blocked), ensure_ascii=False))
37
+ sys.stdout.flush()
38
+ except Exception as exc:
39
+ warn(f"cf_pre_tool_hook.py error: {exc}")
40
+ text = f"SPEC_WORKFLOW_BLOCKED [pre_tool_error]: {exc}"
41
+ sys.stdout.write(json.dumps(pre_tool_payload(text, True), ensure_ascii=False))
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """Stop Hook: run validation.yml checks on session-edited files (FEAT-03).
3
+
4
+ Flow: session edit events (cf_log) → match validator triggers → run commands
5
+ serially under a total budget → failures surface as {"decision": "block",
6
+ "reason": ...} so the agent fixes them before the session truly ends.
7
+
8
+ Protocol notes (deviation from the injection-hook contract, recorded in
9
+ TASK-010): the Stop event's feedback channel is decision/reason — there is no
10
+ additionalContext for Stop. `stop_hook_active` is respected so an already
11
+ continued session is never re-blocked (no loops). All-pass → silent exit 0.
12
+ """
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import time
18
+
19
+ from codeflow.core import cf_log
20
+ from codeflow.core.cf_exec_base import execution_session
21
+ from codeflow.cli.cf_spec_context import load_active_task
22
+ from codeflow.task.cf_task_runtime import run_done_gate
23
+ from codeflow.core.cf_stop_guard import StopAlreadyRunning, stop_guard
24
+ from codeflow.acceptance.cf_acceptance_evidence import line_status, scenario_line
25
+ from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
26
+ from codeflow.core.cf_validators import PARSE_ERROR, VALID, load_validators, run_validators
27
+ from codeflow.acceptance.cf_acceptance_manifest_core import coverage_execution
28
+ from codeflow.core.cf_core import (
29
+ ensure_utf8_io,
30
+ load_config,
31
+ normalize_path,
32
+ resolve_enforcement,
33
+ resolve_quality_loop,
34
+ resolve_session_id,
35
+ timing_log,
36
+ )
37
+
38
+ TOTAL_BUDGET_SECONDS = 30.0
39
+ GATE_BUDGET_SECONDS = 25.0
40
+ _SCENARIO_RE = re.compile(r"\b[SEB]-\d+\b")
41
+ _UNVERIFIED_RE = re.compile(r"\b(?:planned|pending|tbd)\b", re.IGNORECASE)
42
+
43
+
44
+ def session_edited_files(project_root: str, sid: str) -> list:
45
+ files = []
46
+ seen = set()
47
+ for event in cf_log.read_events(project_root, days=2, events=("edit",)):
48
+ if event.get("sid") != sid:
49
+ continue
50
+ rel = (event.get("data") or {}).get("file")
51
+ if rel and rel not in seen:
52
+ seen.add(rel)
53
+ files.append(rel)
54
+ return files
55
+
56
+
57
+ def _root_scoped_files(project_root: str, files: list) -> list:
58
+ """Drop worktree-internal / outside-root paths.
59
+
60
+ Parallel subagents edit another working copy (.code-flow/worktrees/<run>/<TASK>/);
61
+ those paths must not trigger this root's validators.
62
+ """
63
+ root = os.path.realpath(project_root)
64
+ scoped = []
65
+ for rel in files:
66
+ path = normalize_path(rel)
67
+ if path.startswith(".code-flow/worktrees/"):
68
+ continue
69
+ absolute = os.path.realpath(os.path.join(root, path))
70
+ if absolute == root or absolute.startswith(root + os.sep):
71
+ scoped.append(rel)
72
+ return scoped
73
+
74
+
75
+ def _is_active_task_file(rel_path: str) -> bool:
76
+ path = normalize_path(rel_path)
77
+ if not path.startswith(".code-flow/tasks/") or "/archived/" in path:
78
+ return False
79
+ return path.endswith(".md") and not path.endswith((".design.md", ".prd.md", ".scenarios.md"))
80
+
81
+
82
+ def _subsection(section: str, heading: str) -> str:
83
+ match = re.search(
84
+ rf"(?ms)^### {re.escape(heading)}\s*$\n(.*?)(?=^### |^## |\Z)",
85
+ section,
86
+ )
87
+ return match.group(1) if match else ""
88
+
89
+
90
+ def _acceptance_gap(task_id: str, section: str, coverage: str) -> str:
91
+
92
+ status = re.search(r"(?m)^- \*\*Status\*\*: ([^\n]+)", section)
93
+ # done = implementation finished; verified = E2E/final acceptance closed.
94
+ # Both enter the same contract checks; anything else is still in flight.
95
+ if not status or status.group(1).strip() not in ("done", "verified"):
96
+ return ""
97
+ refs_match = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*: ([^\n]+)", section)
98
+ if not refs_match:
99
+ return f"{task_id} 缺少 Acceptance-Refs"
100
+ refs = refs_match.group(1).strip()
101
+ if refs.upper().startswith("N/A"):
102
+ return ""
103
+ scenarios = sorted(set(_SCENARIO_RE.findall(refs)))
104
+ if not scenarios:
105
+ return f"{task_id} Acceptance-Refs 未引用 S/E/B 场景"
106
+ contract = _subsection(section, "Acceptance Contract")
107
+ evidence = _subsection(section, "Acceptance Evidence")
108
+ if not contract or not evidence:
109
+ return f"{task_id} 缺少 Acceptance Contract 或 Acceptance Evidence"
110
+ for scenario in scenarios:
111
+ task_status = status.group(1).strip()
112
+ row_kind, row_exec = coverage_execution(coverage, scenario)
113
+ if task_status == "done" and (row_kind == "e2e" or row_exec == "deferred"):
114
+ allowed = {"verified", "e2e_deferred"}
115
+ elif task_status == "done" and row_kind == "manual":
116
+ # manual 人工验收延后到需求级 verify-e2e 由用户一次性确认(confirm-manual 写回 verified)
117
+ allowed = {"verified", "planned", "pending", "manual_pending"}
118
+ else:
119
+ allowed = {"verified"}
120
+ states = []
121
+ for body in (contract, evidence, coverage):
122
+ found = [line_status(line, scenario) for line in body.splitlines() if scenario_line(line, scenario)]
123
+ states.append(found[-1] if found else "")
124
+ if not all(state in allowed for state in states):
125
+ return f"{task_id} 的 {scenario} 状态未闭环(planned/pending/TBD/failed 或缺少 verified;仅实现阶段 E2E/显式 deferred 可 e2e_deferred、manual 可 manual_pending)"
126
+ return ""
127
+
128
+
129
+ def task_acceptance_failures(project_root: str, files: list) -> list:
130
+ """Check new-format task files; legacy tasks without coverage are ignored."""
131
+ failures = []
132
+ for rel_path in files:
133
+ if not _is_active_task_file(rel_path):
134
+ continue
135
+ path = os.path.join(project_root, normalize_path(rel_path))
136
+ try:
137
+ with open(path, "r", encoding="utf-8") as f:
138
+ text = f.read()
139
+ except FileNotFoundError:
140
+ # Session logs retain paths after a task has moved or been deleted.
141
+ continue
142
+ except OSError as exc:
143
+ cf_log.warn(f"cf_stop_hook task read failed: {exc}")
144
+ continue
145
+ if "## Acceptance Coverage" not in text:
146
+ continue
147
+ coverage = text.split("## Acceptance Coverage", 1)[1]
148
+ boundary = coverage.find("\n## ")
149
+ if boundary != -1:
150
+ coverage = coverage[:boundary]
151
+ try:
152
+ document = parse_task_document(text, source=rel_path)
153
+ except TaskDocumentError as exc:
154
+ if exc.code == "task_document_empty":
155
+ continue
156
+ failures.append({
157
+ "name": "任务段落解析",
158
+ "on_fail": "修复任务文件中重复或损坏的 TASK 段落后再结束",
159
+ "detail": str(exc),
160
+ })
161
+ continue
162
+ for task in document.tasks:
163
+ detail = _acceptance_gap(task.id, task.body, coverage)
164
+ if detail:
165
+ failures.append({
166
+ "name": "任务验收契约",
167
+ "on_fail": "补齐设计场景的测试映射与执行证据后再完成任务",
168
+ "detail": detail,
169
+ })
170
+ return failures
171
+
172
+
173
+ def _reason_text(failures: list, truncated: bool) -> str:
174
+ lines = ["收尾校验未通过(cf-stop):"]
175
+ for item in failures:
176
+ tag = "(未执行)" if item.get("incomplete") else ""
177
+ lines.append(f"✗ {item['name']}{tag}:{item['on_fail']}")
178
+ if item["detail"]:
179
+ lines.append(f" 输出片段: {item['detail'][:200]}")
180
+ if truncated:
181
+ lines.append("(总预算 30s 已用尽,以上为已完成部分)")
182
+ lines.append("请修复后再结束。")
183
+ return "\n".join(lines)
184
+
185
+
186
+ def _active_stop_scope(root: str, sid: str, enforcement: str, deadline: float) -> tuple[list[str], dict[str, str]]:
187
+ try:
188
+ active = load_active_task(root)
189
+ remaining = deadline - time.monotonic()
190
+ done = run_done_gate(root, os.path.join(root, active.task_dir), cheap=True,
191
+ budget=min(GATE_BUDGET_SECONDS, max(remaining, 0.01)))
192
+ except (OSError, ValueError) as exc:
193
+ if enforcement == "required":
194
+ return [], {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: active task is invalid: {exc}"}
195
+ cf_log.append_event(root, "stop_check", {"gate": "invalid_active_nonfatal", "error": str(exc)}, sid)
196
+ return [], {}
197
+ if done.decision != "pass":
198
+ reason = done.message or "当前 TASK required Spec verifier/Evidence 未通过;修复或重新对齐后再 Done。"
199
+ if any(item.get("error_code") == "verifier_budget_exhausted" for item in done.evidence):
200
+ reason += "(验证预算不足,部分 verifier 未运行,请拆分 TASK 或减少验证命令)"
201
+ if enforcement == "required":
202
+ return [], {"decision": "block", "reason": reason}
203
+ cf_log.append_event(root, "stop_check", {"gate": "blocked_nonfatal", "reason": reason}, sid)
204
+ return list(done.files), {}
205
+
206
+
207
+ def _stop_feedback(root: str, sid: str, files: list[str], enforcement: str, deadline: float) -> dict[str, str]:
208
+ if not files:
209
+ return {}
210
+ failures = task_acceptance_failures(root, files)
211
+ truncated = False
212
+ config = load_validators(root)
213
+ if config.state == PARSE_ERROR:
214
+ failures.append({
215
+ "name": "validation.yml",
216
+ "on_fail": "修复 .code-flow/validation.yml 的 YAML/结构错误后再结束",
217
+ "detail": f"invalid_validation_config: {config.error}",
218
+ })
219
+ elif config.state == VALID:
220
+ filtered = config.without_heavy()
221
+ if filtered.validators:
222
+ outcome = run_validators(root, filtered, files, sid, deadline=deadline)
223
+ failures.extend(outcome.failures)
224
+ truncated = outcome.truncated
225
+ if not failures:
226
+ return {}
227
+ if enforcement == "warn":
228
+ cf_log.append_event(root, "stop_check", {"failures": [item["name"] for item in failures], "nonfatal": True}, sid)
229
+ return {}
230
+ return {"decision": "block", "reason": _reason_text(failures, truncated)}
231
+
232
+
233
+ def build_stop_result(project_root: str, sid: str) -> dict[str, str]:
234
+ enforcement = "required"
235
+ try:
236
+ config = load_config(project_root)
237
+ if not config:
238
+ return {}
239
+ enforcement = resolve_enforcement(config)
240
+ if enforcement == "inject":
241
+ return {}
242
+ active = os.path.exists(os.path.join(project_root, ".code-flow/.active-task.json"))
243
+ enabled = resolve_quality_loop(config)["stop_check"]
244
+ if not active and not enabled:
245
+ return {}
246
+ with stop_guard(project_root, sid):
247
+ return _guarded_stop_result(project_root, sid, enforcement, active, enabled)
248
+ except StopAlreadyRunning as exc:
249
+ return {"decision": "block", "reason": str(exc)}
250
+ except Exception as exc:
251
+ cf_log.warn(f"cf_stop_hook error: {exc}")
252
+ return {"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: {exc}"} if enforcement == "required" else {}
253
+
254
+
255
+ def _guarded_stop_result(root: str, sid: str, enforcement: str, active: bool, enabled: bool) -> dict[str, str]:
256
+ deadline = time.monotonic() + TOTAL_BUDGET_SECONDS
257
+ files, payload = _active_stop_scope(root, sid, enforcement, deadline) if active else ([], {})
258
+ if not payload and enabled:
259
+ if not active:
260
+ files = _root_scoped_files(root, session_edited_files(root, sid))
261
+ payload = _stop_feedback(root, sid, files, enforcement, deadline)
262
+ return payload
263
+
264
+
265
+ def process_stop(data: dict[str, object], project_root: str) -> None:
266
+ try:
267
+ if data.get("stop_hook_active"):
268
+ return
269
+ payload = build_stop_result(project_root, resolve_session_id(data))
270
+ if payload:
271
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
272
+ except Exception as exc:
273
+ cf_log.warn(f"cf_stop_hook error: {exc}")
274
+ sys.stdout.write(json.dumps({"decision": "block", "reason": f"SPEC_WORKFLOW_BLOCKED: {exc}"}, ensure_ascii=False))
275
+
276
+
277
+ def _main() -> None:
278
+ try:
279
+ ensure_utf8_io()
280
+ if sys.stdin.isatty():
281
+ cf_log.warn("cf_stop_hook.py: missing stdin event")
282
+ return
283
+ raw = sys.stdin.read()
284
+ if raw.strip():
285
+ process_stop(json.loads(raw), os.getcwd())
286
+ except (json.JSONDecodeError, OSError, ValueError) as exc:
287
+ cf_log.warn(f"cf_stop_hook.py bad_stdin_json: {exc}")
288
+
289
+
290
+ def main() -> None:
291
+ with execution_session():
292
+ _main()
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook for the Context-first three-branch router."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from codeflow.core import cf_log
13
+ from codeflow.acceptance.cf_checks import detect_correction
14
+ from codeflow.core.cf_core import (
15
+ ConfigError,
16
+ ensure_utf8_io,
17
+ load_config,
18
+ normalize_path,
19
+ resolve_quality_loop,
20
+ resolve_session_id,
21
+ timing_log,
22
+ )
23
+ from codeflow.core.cf_session_state import load_session_state, save_session_state
24
+ from codeflow.spec.cf_injection import injection_output, injection_text
25
+ from codeflow.spec.cf_spec_router import RouterError, RouteResult, route_prompt
26
+
27
+
28
+ _PATH_RE = re.compile(r'`([^`\n]+)`|@?((?:[a-zA-Z]:[\\/]|/)?[a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,8})(?![a-zA-Z0-9_])')
29
+ _EXT_RE = re.compile(r'\.(py|js|jsx|ts|tsx|vue|svelte|go|rs|java|rb|cs|cpp|c|h)$')
30
+ _URL_RE = re.compile(r'\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\s`<>]+')
31
+ _HARNESS_RE = re.compile(
32
+ r"<(?P<tag>task-notification|output-file)\b[^>]*>.*?</(?P=tag)>",
33
+ re.IGNORECASE | re.DOTALL,
34
+ )
35
+
36
+ _COMPRESS_INTERVAL = 25
37
+ _REMINDER_TEXT = (
38
+ "⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
39
+ )
40
+
41
+
42
+ def extract_paths_from_prompt(prompt: str, root: str | None = None) -> list[str]:
43
+ """Extract explicit file paths without semantic keyword inference.
44
+
45
+ Paths outside the project are ignored: harness metadata (for example the
46
+ `<output-file>` in a background-agent notification) and pasted logs often
47
+ contain absolute paths that are not routable project files. Only in-project
48
+ paths participate in spec routing.
49
+ """
50
+ paths = []
51
+ seen = set()
52
+ text = _HARNESS_RE.sub("", prompt)
53
+ for match in _PATH_RE.finditer(_URL_RE.sub("", text)):
54
+ candidate = normalize_path((match.group(1) or match.group(2)).lstrip('@'))
55
+ if '/' not in candidate and not _EXT_RE.search(candidate):
56
+ continue
57
+ if root is not None:
58
+ try:
59
+ target = Path(candidate) if Path(candidate).is_absolute() else Path(root) / candidate
60
+ candidate = target.resolve().relative_to(Path(root).resolve()).as_posix()
61
+ except ValueError:
62
+ continue
63
+ if candidate not in seen and ('/' in candidate or _EXT_RE.search(candidate)):
64
+ paths.append(candidate)
65
+ seen.add(candidate)
66
+ return paths
67
+
68
+
69
+ def _payload(text: str, mode: str) -> dict[str, object]:
70
+ payload: dict[str, object] = {
71
+ "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": text}
72
+ }
73
+ if mode == "blocked":
74
+ payload.update(decision="block", reason=text)
75
+ if os.environ.get("CF_DEBUG") == "1":
76
+ payload["debug"] = {"mode": mode}
77
+ return payload
78
+
79
+
80
+ def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> None:
81
+ config = load_config(root)
82
+ if not config or not resolve_quality_loop(config)["correction_capture"]:
83
+ return
84
+ correction = detect_correction(prompt)
85
+ if correction:
86
+ cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
87
+
88
+
89
+ def _session_reminder(root: str, sid: str) -> str:
90
+ """One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
91
+
92
+ Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
93
+ corrupt state file degrades to no reminder rather than breaking the hook.
94
+ """
95
+ config = load_config(root)
96
+ if not config or not resolve_quality_loop(config)["compress_reminder"]:
97
+ return ""
98
+ state = load_session_state(root)
99
+ try:
100
+ if state.get("session_id") != sid:
101
+ state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
102
+ state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
103
+ next_at = int(state.get("next_remind_at", _COMPRESS_INTERVAL))
104
+ reminder = ""
105
+ if state["prompt_count"] >= next_at:
106
+ reminder = _REMINDER_TEXT
107
+ state["next_remind_at"] = next_at + _COMPRESS_INTERVAL
108
+ except (ValueError, TypeError, KeyError):
109
+ return ""
110
+ save_session_state(root, state)
111
+ return reminder
112
+
113
+
114
+ def build_prompt_context(prompt: str, sid: str, root: str) -> tuple[str, str]:
115
+ try:
116
+ return _build_prompt_context(prompt, sid, root)
117
+ except (ConfigError, RouterError, OSError, UnicodeError, ValueError, TypeError) as exc:
118
+ return f"SPEC_WORKFLOW_BLOCKED [{getattr(exc, 'code', 'invalid_prompt_context')}]: {exc}. Run cf-spec doctor before continuing.", "blocked"
119
+
120
+
121
+ def _build_prompt_context(prompt: str, sid: str, root: str) -> tuple[str, str]:
122
+ if not prompt.strip():
123
+ return "", ""
124
+ config = load_config(root)
125
+ active = (Path(root) / ".code-flow/.active-task.json").exists()
126
+ paths = extract_paths_from_prompt(prompt, None if active else root)
127
+ _record_correction(root, prompt, paths, sid)
128
+ result = route_prompt(root, paths, sid)
129
+ reminder = _session_reminder(root, sid) if config and resolve_quality_loop(config)["compress_reminder"] else ""
130
+ text = injection_text(root, sid, result, prompt=True)
131
+ if text and result.mode == "catalog":
132
+ cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
133
+ mode = result.mode if text else "reminder"
134
+ return "\n\n".join(part.rstrip() for part in (text, reminder) if part), mode
135
+
136
+
137
+ def process_prompt(data: dict[str, object], root: str) -> None:
138
+ try:
139
+ prompt = data.get("prompt", "")
140
+ if not isinstance(prompt, str) or not prompt.strip():
141
+ return
142
+ with injection_output():
143
+ text, mode = build_prompt_context(prompt, resolve_session_id(data), root)
144
+ if text:
145
+ sys.stdout.write(json.dumps(_payload(text, mode), ensure_ascii=False))
146
+ sys.stdout.flush()
147
+ except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
148
+ cf_log.warn(f"cf_user_prompt_hook error: {exc}")
149
+
150
+
151
+ def main() -> None:
152
+ try:
153
+ ensure_utf8_io()
154
+ if sys.stdin.isatty():
155
+ cf_log.warn("cf_user_prompt_hook.py: missing stdin event")
156
+ return
157
+ raw = sys.stdin.read()
158
+ if raw.strip():
159
+ process_prompt(json.loads(raw), os.getcwd())
160
+ except (json.JSONDecodeError, OSError, ValueError) as exc:
161
+ cf_log.warn(f"cf_user_prompt_hook.py bad_stdin_json: {exc}")