@michaelxwb/code-flow 0.6.4 → 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 -40
  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 +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +67 -35
  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 +73 -34
  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 -40
  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 +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  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 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  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 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  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} +37 -3
  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} +252 -26
  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 -299
  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
@@ -1,178 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """UserPromptSubmit hook for the Context-first three-branch router."""
3
-
4
- import json
5
- import os
6
- import re
2
+ """Path-frozen entry shim: implementation lives in codeflow.hooks.cf_user_prompt_hook."""
7
3
  import sys
8
4
 
9
- import cf_log
10
- from cf_checks import detect_correction
11
- from cf_core import (
12
- _log,
13
- ensure_utf8_io,
14
- load_config,
15
- normalize_path,
16
- resolve_enforcement,
17
- resolve_quality_loop,
18
- resolve_session_id,
19
- timing_log,
20
- )
21
- from cf_session_state import load_session_state, save_session_state
22
- from cf_spec_context import injection_version
23
- from cf_spec_router import RouterError, route_prompt
24
-
25
-
26
- _PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,6})(?![a-zA-Z0-9_])')
27
- _EXT_RE = re.compile(r'\.(py|js|ts|go|rs|java|rb|cs|cpp|c|h)$')
28
-
29
- _COMPRESS_INTERVAL = 25
30
- _REINJECT_INTERVAL = 25
31
- _REMINDER_TEXT = (
32
- "⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
33
- )
34
- _WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
35
-
36
-
37
- def extract_paths_from_prompt(prompt: str) -> list[str]:
38
- """Extract explicit file paths without semantic keyword inference."""
39
- paths = []
40
- seen = set()
41
- for match in _PATH_RE.finditer(prompt):
42
- candidate = normalize_path(match.group(1).lstrip('@`'))
43
- if candidate not in seen and ('/' in candidate or _EXT_RE.search(candidate)):
44
- paths.append(candidate)
45
- seen.add(candidate)
46
- return paths
47
-
48
-
49
- def _payload(text: str, mode: str) -> dict[str, object]:
50
- payload: dict[str, object] = {
51
- "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": text}
52
- }
53
- if os.environ.get("CF_DEBUG") == "1":
54
- payload["debug"] = {"mode": mode}
55
- return payload
56
-
57
-
58
- def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> None:
59
- config = load_config(root)
60
- if not config or not resolve_quality_loop(config)["correction_capture"]:
61
- return
62
- correction = detect_correction(prompt)
63
- if correction:
64
- cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
65
-
66
-
67
- def _session_reminder(root: str, sid: str) -> str:
68
- """One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
69
-
70
- Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
71
- corrupt state file degrades to no reminder rather than breaking the hook.
72
- """
73
- config = load_config(root)
74
- if not config or not resolve_quality_loop(config)["compress_reminder"]:
75
- return ""
76
- state = load_session_state(root)
77
- try:
78
- if state.get("session_id") != sid:
79
- state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
80
- state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
81
- reminder = ""
82
- if state["prompt_count"] >= int(state.get("next_remind_at", _COMPRESS_INTERVAL)):
83
- reminder = _REMINDER_TEXT
84
- state["next_remind_at"] = int(state["next_remind_at"]) + _COMPRESS_INTERVAL
85
- except (ValueError, TypeError):
86
- return ""
87
- save_session_state(root, state)
88
- return reminder
89
-
90
-
91
- def _route_with_enforcement(root: str, paths: list[str], sid: str, enforcement: str) -> tuple[object, object]:
92
- """Route with enforcement-aware failure handling.
93
-
94
- required — fail closed with the SPEC_WORKFLOW_BLOCKED message;
95
- warn — degrade to a short advisory message;
96
- inject — degrade silently.
97
- Returns (result, error_payload); exactly one is not None.
98
- """
99
- try:
100
- return route_prompt(root, paths, sid), None
101
- except RouterError as exc:
102
- if enforcement == "required":
103
- message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
104
- return None, _payload(message, "blocked")
105
- if enforcement == "warn":
106
- return None, _payload(_WARN_TEXT.format(message=str(exc)), "warn")
107
- return None, None
108
-
109
-
110
- def main() -> None:
111
- try:
112
- ensure_utf8_io()
113
- raw = sys.stdin.read()
114
- if not raw.strip():
115
- return
116
- data = json.loads(raw)
117
- prompt = data.get("prompt", "")
118
- if not isinstance(prompt, str) or not prompt.strip():
119
- return
120
- root = os.getcwd()
121
- sid = resolve_session_id(data)
122
- config = load_config(root)
123
- enforcement = resolve_enforcement(config) if config else "required"
124
- paths = extract_paths_from_prompt(prompt)
125
- _record_correction(root, prompt, paths, sid)
126
- result, error_payload = _route_with_enforcement(root, paths, sid, enforcement)
127
- if error_payload is not None:
128
- sys.stdout.write(json.dumps(error_payload, ensure_ascii=False))
129
- return
130
- if result is None:
131
- return
132
- reminder = ""
133
- if config and resolve_quality_loop(config)["compress_reminder"]:
134
- reminder = _session_reminder(root, sid)
135
- text = result.text
136
- if result.mode == "task" and result.context_sha256:
137
- # Dedup: inject once per session/task/contract version, refreshing
138
- # at the compress cadence so long sessions do not re-pay 12k chars
139
- # on every prompt. The key covers task_id and the TASK contract
140
- # digest (not just the Context hash) so switching TASKs on a
141
- # shared Context always re-injects the new contract.
142
- state = load_session_state(root)
143
- count = int(state.get("prompt_count", 0))
144
- dirty = False
145
- if not (config and resolve_quality_loop(config)["compress_reminder"]):
146
- # compress_reminder off → _session_reminder never bumps the
147
- # counter; maintain the cadence here so the periodic refresh
148
- # still fires after compaction.
149
- count += 1
150
- state["prompt_count"] = count
151
- dirty = True
152
- version = injection_version(root, sid, result.context_sha256)
153
- injected = state.get("injected_version")
154
- last_inject = int(state.get("last_inject_prompt", 0))
155
- if injected == version and count - last_inject < _REINJECT_INTERVAL:
156
- text = ""
157
- else:
158
- state["injected_version"] = version
159
- state["last_inject_prompt"] = count
160
- dirty = True
161
- if dirty:
162
- save_session_state(root, state)
163
- if not text and not reminder:
164
- return
165
- if text:
166
- cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
167
- if text and reminder:
168
- text = text.rstrip() + "\n\n" + reminder
169
- elif reminder:
170
- text = reminder
171
- sys.stdout.write(json.dumps(_payload(text, result.mode if text else "reminder"), ensure_ascii=False))
172
- except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
173
- _log(f"cf_user_prompt_hook error: {exc}")
174
-
5
+ from codeflow.hooks import cf_user_prompt_hook as _impl
175
6
 
176
7
  if __name__ == "__main__":
177
- main()
178
- timing_log("cf_user_prompt_hook")
8
+ _impl.main()
9
+ _impl.timing_log("cf_user_prompt_hook")
10
+ else:
11
+ sys.modules[__name__] = _impl
@@ -1,81 +1,10 @@
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
1
+ #!/usr/bin/env python3
2
+ """Path-frozen entry shim: implementation lives in codeflow.cli.cf_validation."""
10
3
  import sys
11
4
 
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
-
5
+ from codeflow.cli import cf_validation as _impl
79
6
 
80
7
  if __name__ == "__main__":
81
- raise SystemExit(main())
8
+ raise SystemExit(_impl.main())
9
+ else:
10
+ sys.modules[__name__] = _impl
@@ -0,0 +1,347 @@
1
+ """Append execution history without erasing the user's acceptance contract."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Mapping, Optional, Sequence
9
+ from uuid import uuid4
10
+
11
+ from codeflow.evidence.cf_evidence_store import EvidenceStore, discover_project_root, git_commit
12
+ from codeflow.acceptance.cf_acceptance_schema import check_outcome, load_manifest, recorded_check_hash, scenario_check_ids
13
+
14
+ STATES = frozenset(("planned", "pending", "tbd", "verified", "unverified", "stale", "failed", "incomplete", "not_configured", "e2e_deferred", "env_blocked", "manual_pending", "accepted_risk"))
15
+ _COVERAGE_STATES = STATES | {"designed"}
16
+ _EVIDENCE_STATUS = {
17
+ "passed": "passed",
18
+ "failed": "failed",
19
+ "incomplete": "incomplete",
20
+ "not_configured": "not_configured",
21
+ "e2e_deferred": "deferred",
22
+ "env_blocked": "env_blocked",
23
+ "accepted_risk": "accepted_risk",
24
+ }
25
+
26
+
27
+ def scenario_line(line: str, scenario_id: str) -> bool:
28
+ return bool(re.match(rf"^\s*(?:\|\s*|[-*]\s+){re.escape(scenario_id)}(?=\s*[:|])", line))
29
+
30
+
31
+ def line_status(line: str, scenario_id: str) -> str:
32
+ if not scenario_line(line, scenario_id):
33
+ return ""
34
+ if line.lstrip().startswith("|"):
35
+ cells = [cell.strip().lower() for cell in line.strip().strip("|").split("|")]
36
+ return next((cell for cell in reversed(cells) if cell in _COVERAGE_STATES), "")
37
+ match = re.match(r"^\s*[-*]\s+[^:]+:\s*([a-z0-9_]+)\b", line, re.I)
38
+ return match.group(1).lower() if match and match.group(1).lower() in _COVERAGE_STATES else ""
39
+
40
+
41
+ def _status_index(header: str) -> int:
42
+ cells = [cell.strip().lower() for cell in header.strip().strip("|").split("|")]
43
+ for index, cell in enumerate(cells):
44
+ if cell in ("状态", "status"):
45
+ return index + 1
46
+ return -1
47
+
48
+
49
+ def _status_line(line: str, scenario_id: str, status: str, status_index: int = -1) -> str:
50
+ if not scenario_line(line, scenario_id):
51
+ return line
52
+ if line.lstrip().startswith("|"):
53
+ cells = line.split("|")
54
+ if 0 < status_index < len(cells) - 1:
55
+ cells[status_index] = f" {status} "
56
+ return "|".join(cells)
57
+ for index in range(len(cells) - 1, 0, -1):
58
+ if cells[index].strip().lower() in _COVERAGE_STATES:
59
+ cells[index] = f" {status} "
60
+ return "|".join(cells)
61
+ return line.rstrip().rstrip("|") + f"| {status} |"
62
+ prefix = r"(^\s*[-*]\s+[^:]+:\s*)"
63
+ if line_status(line, scenario_id):
64
+ return re.sub(prefix + r"(?:" + "|".join(sorted(_COVERAGE_STATES)) + r")\b", rf"\g<1>{status}", line, count=1, flags=re.I)
65
+ return re.sub(prefix, rf"\g<1>{status} — ", line, count=1)
66
+
67
+
68
+ def _patch_section(section: str, scenario_id: str, status: str, record: str) -> str:
69
+ contract = re.search(r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
70
+ if contract:
71
+ lines = contract.group(1).splitlines()
72
+ header = next((line for line in lines if line.lstrip().startswith("|")
73
+ and ("状态" in line or "status" in line.lower())), "")
74
+ index = _status_index(header)
75
+ body = "\n".join(_status_line(line, scenario_id, status, index) for line in lines) + "\n"
76
+ section = section[:contract.start(1)] + body + section[contract.end(1):]
77
+ evidence = re.search(r"(?ms)^### Acceptance Evidence[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
78
+ if evidence:
79
+ body = evidence.group(1)
80
+ if record not in body.splitlines():
81
+ body = body.rstrip() + "\n" + record + "\n\n"
82
+ section = section[:evidence.start(1)] + body + section[evidence.end(1):]
83
+ else:
84
+ section = section.rstrip() + "\n\n### Acceptance Evidence\n" + record + "\n"
85
+ return section
86
+
87
+
88
+ def sync_task_evidence(task: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "", status: str = "verified") -> str:
89
+ """Sync one scenario's status into the task file; returns what was written.
90
+
91
+ - owner + TASK section → contract/evidence + coverage
92
+ - ownerless legacy scenario → coverage only (a scenario with neither owner
93
+ nor coverage has no truthful place to attribute evidence; first-TASK
94
+ attribution corrupted unrelated contracts, so it is skipped)
95
+ """
96
+ text = task.read_text(encoding="utf-8")
97
+ record = f"- {scenario_id}: {status} — {evidence} (confirmed_by: {confirmed_by})"
98
+ if owner:
99
+ task_pattern = re.escape(owner)
100
+ match = re.search(rf"(?ms)^##\s+{task_pattern}:.*?(?=^## |\Z)", text)
101
+ if match is None:
102
+ raise ValueError(f"acceptance owner missing: {owner}")
103
+ section = _patch_section(match.group(0), scenario_id, status, record)
104
+ text = text[:match.start()] + section + text[match.end():]
105
+ coverage = re.search(r"(?ms)^## Acceptance Coverage[^\n]*\n(.*?)(?=^## |\Z)", text)
106
+ if coverage:
107
+ lines = coverage.group(1).splitlines()
108
+ header = next((line for line in lines if line.lstrip().startswith("|")
109
+ and ("状态" in line or "status" in line.lower())), "")
110
+ index = _status_index(header)
111
+ body = "\n".join(_status_line(line, scenario_id, status, index) for line in lines) + "\n"
112
+ text = text[:coverage.start(1)] + body + text[coverage.end(1):]
113
+ elif not owner:
114
+ return "skipped_no_owner"
115
+ from codeflow.core.cf_fs import atomic_write_text
116
+ atomic_write_text(task, text)
117
+ return "synced"
118
+
119
+
120
+ def _run_record(row: Mapping[str, object], result: Mapping[str, object], run_id: str,
121
+ executed_at: str, fingerprint: str, commit: str,
122
+ check_config: Optional[Mapping[str, object]] = None) -> dict[str, object]:
123
+ source = check_config if isinstance(check_config, Mapping) else row
124
+ command = source.get("command")
125
+ if not isinstance(command, list):
126
+ command = []
127
+ timeout = source.get("timeout", 60)
128
+ cwd = source.get("cwd", ".")
129
+ state = str(result.get("status"))
130
+ exit_code = result.get("exit_code")
131
+ return {
132
+ "run_id": run_id,
133
+ "check_id": str(row.get("check") or row.get("id")),
134
+ "scenario_id": str(row.get("id")),
135
+ "command": command,
136
+ "cwd": cwd if isinstance(cwd, str) else ".",
137
+ "timeout": timeout if isinstance(timeout, (int, float)) else 60,
138
+ "input_hash": fingerprint if isinstance(fingerprint, str) else "",
139
+ "exit_code": exit_code if isinstance(exit_code, int) else (0 if state == "passed" else 1),
140
+ "status": _EVIDENCE_STATUS[state],
141
+ "executed_at": executed_at,
142
+ "source": str(result.get("source") or "local"),
143
+ "environment_hash": str(result.get("environment_hash") or ""),
144
+ "commit": commit,
145
+ "run_url": str(result.get("run_url") or ""),
146
+ }
147
+
148
+
149
+ def _outcome_mapping(result: Mapping[str, object], field: str) -> Mapping[str, object]:
150
+ value = result.get(field)
151
+ return value if isinstance(value, Mapping) else {}
152
+
153
+
154
+ def _store_evidence_runs(path: Path, rows: Mapping[str, Mapping[str, object]],
155
+ results: Sequence[Mapping[str, object]], run_id: str,
156
+ executed_at: str, fingerprint: str) -> dict[str, int]:
157
+ from codeflow.evidence.cf_evidence_store import requirement_key
158
+ project = discover_project_root(path.parent)
159
+ store = EvidenceStore(project, requirement_key(path.parent))
160
+ commit = git_commit(project)
161
+ created = duplicates = 0
162
+ for result in results:
163
+ state = str(result.get("status"))
164
+ if state not in _EVIDENCE_STATUS:
165
+ continue
166
+ if result.get("cache_reused") is True and result.get("checkpoint_reused") is not True:
167
+ continue
168
+ row = rows.get(str(result.get("id")))
169
+ if row is None:
170
+ continue
171
+ result_fingerprint = str(result.get("input_hash") or fingerprint)
172
+ raw_ids = result.get("check_ids")
173
+ check_ids = [str(value) for value in raw_ids] if isinstance(raw_ids, list) else []
174
+ check_id = str(row.get("check") or row.get("id"))
175
+ if not check_ids:
176
+ check_ids = [check_id]
177
+ check_hashes = _outcome_mapping(result, "check_hashes")
178
+ check_reused = _outcome_mapping(result, "check_reused")
179
+ check_checkpoint = _outcome_mapping(result, "checkpoint_reused_checks")
180
+ configs = _outcome_mapping(result, "check_configs")
181
+ for item_id in check_ids:
182
+ checkpointed = check_checkpoint.get(item_id, result.get("checkpoint_reused"))
183
+ item_hash = str(check_hashes.get(item_id) or result_fingerprint)
184
+ if check_reused.get(item_id) is True:
185
+ if checkpointed is not True:
186
+ continue
187
+ if any(
188
+ run.get("input_hash") == item_hash
189
+ and run.get("status") in ("passed", "verified")
190
+ for run in store.list_runs(item_id)
191
+ ):
192
+ continue
193
+ config = configs.get(item_id)
194
+ record = _run_record(
195
+ row, check_outcome(result, item_id), run_id, executed_at, item_hash, commit,
196
+ config if isinstance(config, Mapping) else None,
197
+ )
198
+ record["check_id"] = item_id
199
+ outcome = store.write_run(item_id, record)
200
+ created += outcome["status"] == "created"
201
+ duplicates += outcome["status"] == "duplicate"
202
+ return {"created": created, "duplicates": duplicates}
203
+
204
+
205
+ def _restore_checkpoint(row: dict[str, object], result: Mapping[str, object], run_key: str,
206
+ executed_at: str, fingerprint: str) -> Optional[tuple[dict[str, object], str]]:
207
+ if result["status"] != "passed" or row.get("status") == "verified":
208
+ return None
209
+ row["revision"] = int(row.get("revision", 0)) + 1
210
+ record: dict[str, object] = {
211
+ "id": result["id"],
212
+ "kind": str(result.get("kind", "functional")),
213
+ "status": "passed",
214
+ "exit_code": 0,
215
+ "cache_reused": True,
216
+ "checkpoint_reused": True,
217
+ "revision": row["revision"],
218
+ "run_id": run_key,
219
+ "executed_at": executed_at,
220
+ "command": row.get("command"),
221
+ "cwd": row.get("cwd", "."),
222
+ "timeout": row.get("timeout", 60),
223
+ }
224
+ if fingerprint:
225
+ record["fingerprint"] = fingerprint
226
+ for key in ("check_ids", "check_hashes", "check_results", "check_reused", "checkpoint_reused_checks", "check_configs", "input_hash", "risk"):
227
+ if key in result:
228
+ record[key] = result[key]
229
+ row["status"] = "verified"
230
+ row.pop("stale_checks", None)
231
+ row.pop("stale_reason", None)
232
+ return record, "verified"
233
+
234
+
235
+ def _record_result(row: dict[str, object], result: Mapping[str, object], run_key: str,
236
+ executed_at: str, fingerprint: str) -> Optional[tuple[dict[str, object], str]]:
237
+ """Apply one result to a manifest row; None means keep the row unchanged."""
238
+ state = str(result["status"])
239
+ if result.get("cache_reused") is True:
240
+ return _restore_checkpoint(row, result, run_key, executed_at, fingerprint)
241
+ # A deferral placeholder (e2e skipped in a non-E2E run) must never
242
+ # downgrade a verified terminal scenario: verify-e2e runs first, then
243
+ # later Done/finish passes would otherwise silently un-verify it.
244
+ if state == "e2e_deferred" and row.get("status") == "verified":
245
+ return None
246
+ # An unavailable environment is not new product evidence: keep a prior
247
+ # verified conclusion, but never promote an unverified row.
248
+ if state == "env_blocked" and row.get("status") == "verified":
249
+ return None
250
+ row["revision"] = int(row.get("revision", 0)) + 1
251
+ status = "verified" if state == "passed" else state
252
+ row["status"] = status
253
+ row.pop("stale_checks", None)
254
+ row.pop("stale_reason", None)
255
+ record = {**result, "revision": row["revision"], "run_id": run_key, "executed_at": executed_at}
256
+ # Identity for cross-run reuse checks: command/cwd/timeout + content
257
+ # fingerprint of the worktree the command actually ran against.
258
+ record["command"] = row.get("command")
259
+ record["cwd"] = row.get("cwd", ".")
260
+ record["timeout"] = row.get("timeout", 60)
261
+ if fingerprint and status == "verified":
262
+ record["fingerprint"] = fingerprint
263
+ return record, status
264
+
265
+
266
+ def stale_scenario_checks(row: Mapping[str, object], hashes: Mapping[str, Mapping[str, object]],
267
+ fingerprint: str) -> list[str]:
268
+ """Compare every declared check; a missing or unavailable hash is stale."""
269
+ if row.get("status") not in ("verified", "accepted_risk", "stale"):
270
+ return []
271
+ if row.get("kind") == "manual" or row.get("exec") in ("manual", "aspirational"):
272
+ return []
273
+ check_ids = scenario_check_ids(row)
274
+ evidence = row.get("evidence")
275
+ evidence = evidence if isinstance(evidence, Mapping) else {}
276
+ if not check_ids:
277
+ return [] if fingerprint and evidence.get("fingerprint") == fingerprint else [str(row["id"])]
278
+ stale = []
279
+ for check_id in check_ids:
280
+ expected = hashes.get(check_id, {}).get("input_hash")
281
+ actual = recorded_check_hash(row, check_id)
282
+ if not expected or expected != actual:
283
+ stale.append(check_id)
284
+ return stale
285
+
286
+
287
+ def invalidate_manifest_evidence(root: Path, manifest: Path,
288
+ fingerprint: str) -> list[dict[str, object]]:
289
+ """Mark outdated conclusions without changing historical execution evidence."""
290
+ from codeflow.acceptance.cf_check_inputs import check_input_hashes
291
+ from codeflow.core.cf_fs import atomic_write_text
292
+ data = load_manifest(manifest)
293
+ checks = {str(check["id"]): check for check in data.get("checks", [])}
294
+ hashes = check_input_hashes(str(root), checks, fingerprint)
295
+ changed: list[dict[str, object]] = []
296
+ for row in data["scenarios"]:
297
+ stale = stale_scenario_checks(row, hashes, fingerprint)
298
+ if not stale:
299
+ continue
300
+ row.update(status="stale", stale_checks=stale)
301
+ changed.append({"scenario_id": row["id"], "checks": stale})
302
+ if not changed:
303
+ return []
304
+ atomic_write_text(manifest, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
305
+ task_name = data.get("task_file")
306
+ if isinstance(task_name, str) and task_name:
307
+ task = (manifest.parent / task_name).resolve()
308
+ rows = {row["id"]: row for row in data["scenarios"]}
309
+ for item in changed:
310
+ row = rows[item["scenario_id"]]
311
+ sync_task_evidence(task, str(row["id"]), "runtime",
312
+ "input changed: " + ", ".join(item["checks"]),
313
+ str(row.get("owner", "")), "stale")
314
+ return changed
315
+
316
+
317
+ def persist_results(path: Path, data: dict[str, object], results: Sequence[Mapping[str, object]],
318
+ fingerprint: str = "", run_id: str = "") -> None:
319
+ rows = {row["id"]: row for row in data["scenarios"]}
320
+ run_key, executed_at = run_id or uuid4().hex, datetime.now(timezone.utc).isoformat()
321
+ updates = []
322
+ for result in results:
323
+ state = str(result["status"])
324
+ if state in ("not_included", "manual_pending"):
325
+ continue
326
+ row = rows[result["id"]]
327
+ result_fingerprint = str(result.get("input_hash") or fingerprint)
328
+ applied = _record_result(row, result, run_key, executed_at, result_fingerprint)
329
+ if applied is None:
330
+ continue
331
+ record, status = applied
332
+ row["evidence"] = record
333
+ if result.get("cache_reused") is not True:
334
+ runs = row.setdefault("runs", [])
335
+ runs.append(record)
336
+ if len(runs) > 50:
337
+ del runs[:-50]
338
+ updates.append((row, record, status))
339
+ from codeflow.core.cf_fs import atomic_write_text
340
+ atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
341
+ _store_evidence_runs(path, rows, results, run_key, executed_at, fingerprint)
342
+ task_name = data.get("task_file")
343
+ if isinstance(task_name, str) and task_name:
344
+ task = Path(task_name) if Path(task_name).is_absolute() else path.parent / task_name
345
+ for row, record, status in updates:
346
+ detail = f"automated command {record['status']}; run_id={run_key}"
347
+ sync_task_evidence(task, str(row["id"]), "runner", detail, str(row.get("owner", "")), status)