@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
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """Shared execution substrate: deadline-aware argv subprocess runner.
3
+
4
+ All check executors (acceptance scenarios, Spec verifiers, session
5
+ validators) funnel through here so one entry deadline constrains the whole
6
+ chain, commands never pass through a shell, and unrun work is reported as
7
+ `incomplete` instead of silently skipped or falsely passed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import os
14
+ import re
15
+ import signal
16
+ import shlex
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from contextlib import contextmanager
22
+ from contextvars import ContextVar
23
+ from pathlib import Path
24
+ from typing import Callable, Iterator, Mapping, Optional, Sequence
25
+
26
+ _EXECUTIONS: ContextVar[Optional[dict[str, dict[str, object]]]] = ContextVar("cf_executions", default=None)
27
+ _REPORTS: ContextVar[Optional[list[dict[str, object]]]] = ContextVar("cf_reports", default=None)
28
+ _SECRET_OPTION = re.compile(r"(?i)^--?(?:api[-_]?key|access[-_]?token|token|password|passwd|secret|credential)$")
29
+ _SECRET_ASSIGNMENT = re.compile(r"(?i)(--?(?:api[-_]?key|access[-_]?token|token|password|passwd|secret|credential)=)([^\s]+)")
30
+ _RUNTIME_ENV = frozenset(("CF_RUNTIME_COMMAND", "CF_RUNTIME_ACTION", "CF_CLI_VERSION"))
31
+
32
+
33
+ def redact_sensitive_text(value: str) -> str:
34
+ """Hide inherited sensitive environment values from diagnostics."""
35
+ names = ("SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "API_KEY", "PRIVATE_KEY")
36
+ secrets = {item for key, item in os.environ.items()
37
+ if item and any(name in key.upper() for name in names)}
38
+ for secret in sorted(secrets, key=len, reverse=True):
39
+ value = value.replace(secret, "[REDACTED]")
40
+ return _SECRET_ASSIGNMENT.sub(r"\1[REDACTED]", value)
41
+
42
+
43
+ def _redact_sequence(values: Sequence[object]) -> list[object]:
44
+ result: list[object] = []
45
+ hide_next = False
46
+ for item in values:
47
+ if hide_next:
48
+ result.append("[REDACTED]")
49
+ hide_next = False
50
+ continue
51
+ result.append(redact_sensitive_payload(item))
52
+ hide_next = isinstance(item, str) and bool(_SECRET_OPTION.fullmatch(item))
53
+ return result
54
+
55
+
56
+ def redact_sensitive_payload(value: object) -> object:
57
+ """Return a JSON-compatible copy with sensitive strings redacted."""
58
+ if isinstance(value, str):
59
+ return redact_sensitive_text(value)
60
+ if isinstance(value, Mapping):
61
+ return {key: redact_sensitive_payload(item) for key, item in value.items()}
62
+ if isinstance(value, (list, tuple)):
63
+ return _redact_sequence(value)
64
+ return value
65
+
66
+
67
+ @contextmanager
68
+ def report_session() -> Iterator[list[dict[str, object]]]:
69
+ reports: list[dict[str, object]] = []
70
+ token = _REPORTS.set(reports)
71
+ try:
72
+ yield reports
73
+ finally:
74
+ _REPORTS.reset(token)
75
+
76
+
77
+ def record_report(report: Mapping[str, object]) -> None:
78
+ reports = _REPORTS.get()
79
+ if reports is not None:
80
+ reports.append(dict(report))
81
+
82
+
83
+ def report_requested() -> bool:
84
+ return _REPORTS.get() is not None
85
+
86
+
87
+ @contextmanager
88
+ def execution_session() -> Iterator[None]:
89
+ """Share results only inside one validation operation, never across turns."""
90
+ if _EXECUTIONS.get() is not None:
91
+ yield
92
+ return
93
+ token = _EXECUTIONS.set({})
94
+ try:
95
+ yield
96
+ finally:
97
+ _EXECUTIONS.reset(token)
98
+
99
+
100
+ def invalidate_executions() -> None:
101
+ cache = _EXECUTIONS.get()
102
+ if cache is not None:
103
+ cache.clear()
104
+
105
+
106
+ def command_environment(env: Optional[Mapping[str, str]] = None) -> dict[str, str]:
107
+ """Do not pass CLI dispatch metadata into user checks."""
108
+ environment = {key: value for key, value in os.environ.items() if key not in _RUNTIME_ENV}
109
+ environment.update({str(key): str(value) for key, value in (env or {}).items()})
110
+ return environment
111
+
112
+
113
+ def execution_key(argv: Sequence[str], cwd: str, timeout: float,
114
+ env: Optional[Mapping[str, str]] = None) -> str:
115
+ """Identity of a command under the current process environment."""
116
+ environment = command_environment(env)
117
+ payload: list[object] = [list(argv), str(Path(cwd).resolve()), float(timeout), sorted(environment.items())]
118
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False).encode()).hexdigest()
119
+
120
+
121
+ def execution_environment_hash(env: Optional[Mapping[str, str]] = None) -> str:
122
+ """Bind evidence to inherited and explicit environment without exposing values."""
123
+ environment = command_environment(env)
124
+ payload = json.dumps(sorted(environment.items()), ensure_ascii=False)
125
+ return hashlib.sha256(payload.encode()).hexdigest()
126
+
127
+
128
+ def remaining_seconds(deadline: Optional[float]) -> Optional[float]:
129
+ """Seconds left until the absolute monotonic deadline (None = unbounded)."""
130
+ if deadline is None:
131
+ return None
132
+ return deadline - time.monotonic()
133
+
134
+
135
+ def build_argv(template: str, files: Sequence[str]) -> list[str]:
136
+ """Expand a validator command template into argv without a shell.
137
+
138
+ `{files}` must be a standalone token and expands to one argv entry per
139
+ file (spaces/metachars safe). Embedded usage is a config error — refusing
140
+ is safer than reintroducing shell splitting.
141
+ """
142
+ tokens = shlex.split(template or "")
143
+ if not tokens:
144
+ return []
145
+ argv: list[str] = []
146
+ for token in tokens:
147
+ if token == "{files}":
148
+ argv.extend(files)
149
+ elif "{files}" in token:
150
+ raise ValueError(f"validator command must keep {{files}} as a standalone token: {template!r}")
151
+ else:
152
+ argv.append(token)
153
+ return argv
154
+
155
+
156
+ def run_command(
157
+ argv: Sequence[str],
158
+ cwd: str,
159
+ timeout: float,
160
+ deadline: Optional[float] = None,
161
+ env: Optional[Mapping[str, str]] = None,
162
+ identity_context: Optional[Mapping[str, object]] = None,
163
+ ) -> dict[str, object]:
164
+ """Run argv synchronously under timeout+deadline. Never uses a shell."""
165
+ key = execution_key(argv, cwd, timeout, env)
166
+ if identity_context:
167
+ payload = json.dumps(identity_context, sort_keys=True, ensure_ascii=False, default=str)
168
+ key = hashlib.sha256(f"{key}:{payload}".encode()).hexdigest()
169
+ cache = _EXECUTIONS.get()
170
+ fingerprint = ""
171
+ if cache is not None:
172
+ root = _fingerprint_git(cwd, "rev-parse", "--show-toplevel")
173
+ if root is not None and root.returncode == 0:
174
+ fingerprint = worktree_fingerprint(root.stdout.strip())
175
+ key = hashlib.sha256(f"{key}:{fingerprint}".encode()).hexdigest() if fingerprint else ""
176
+ if cache is not None and key and key in cache:
177
+ return {**cache[key], "execution_ms": 0.0, "execution_reused": True}
178
+ started = time.monotonic()
179
+ result = _execute(argv, cwd, timeout, deadline, env)
180
+ result["execution_ms"] = ((time.monotonic() - started) * 1000
181
+ if result.get("execution_started", True) else 0.0)
182
+ result["execution_reused"] = False
183
+ if cache is not None and key and result["status"] == "ok" and result["returncode"] == 0:
184
+ cache[key] = result
185
+ return result
186
+
187
+
188
+ def hash_worktree_paths(project_root: str, paths: Sequence[str]) -> Optional[list]:
189
+ """Batch-hash worktree file contents (single `git hash-object` process)."""
190
+ if not paths:
191
+ return []
192
+ try:
193
+ completed = subprocess.run(
194
+ ("git", "-C", project_root, "hash-object", "--stdin-paths"),
195
+ input="\n".join(paths) + "\n", text=True, capture_output=True, timeout=5,
196
+ )
197
+ except (OSError, subprocess.TimeoutExpired) as exc:
198
+ sys.stderr.write(f"worktree hashing failed: {exc}\n")
199
+ return None
200
+ if completed.returncode != 0:
201
+ return None
202
+ hashes = completed.stdout.splitlines()
203
+ if len(hashes) != len(paths):
204
+ return None
205
+ return [(path, sha.strip()) for path, sha in zip(paths, hashes)]
206
+
207
+
208
+ def _fingerprint_git(root: str, *arguments: str) -> Optional[subprocess.CompletedProcess]:
209
+ try:
210
+ return subprocess.run(
211
+ ("git", "-C", root, *arguments), text=True, encoding="utf-8",
212
+ capture_output=True, check=False, timeout=5,
213
+ )
214
+ except (OSError, subprocess.TimeoutExpired) as exc:
215
+ sys.stderr.write(f"worktree fingerprint failed: {exc}\n")
216
+ return None
217
+
218
+
219
+ def _runtime_path(path: str) -> bool:
220
+ return path.startswith(".code-flow/") or Path(path).name == ".acceptance-manifest.json"
221
+
222
+
223
+ def _fingerprint_changes(status: str, contents: dict[str, str], include_untracked: bool,
224
+ untracked_filter: Optional[Callable[[str], bool]]) -> Optional[list[str]]:
225
+ changed: list[str] = []
226
+ for line in status.splitlines():
227
+ code, path = line[:2], line[3:].strip()
228
+ if " -> " in path:
229
+ path = path.split(" -> ", 1)[1].strip()
230
+ if not path or _runtime_path(path):
231
+ continue
232
+ if path.startswith('"'):
233
+ return None # Quoted/escaped paths cannot be identified reliably.
234
+ if code.strip() == "??":
235
+ if include_untracked and (untracked_filter is None or untracked_filter(path)):
236
+ changed.append(path)
237
+ elif "D" in code:
238
+ contents.pop(path, None)
239
+ else:
240
+ changed.append(path)
241
+ return changed
242
+
243
+
244
+ def worktree_fingerprint(project_root: str, untracked_filter: Optional[Callable[[str], bool]] = None,
245
+ include_untracked: bool = True) -> str:
246
+ """Hash real content including new files, excluding workflow runtime state.
247
+
248
+ Staging/committing unchanged bytes does not change identity. Ignored files
249
+ never participate; test outputs must be Git-ignored. Explicit check inputs
250
+ are hashed separately. Return an empty identity when it cannot be proven.
251
+ """
252
+ tree = _fingerprint_git(project_root, "ls-tree", "-r", "HEAD")
253
+ status = _fingerprint_git(project_root, "status", "--porcelain", "--untracked-files=all")
254
+ if tree is None or status is None or tree.returncode != 0 or status.returncode != 0:
255
+ return ""
256
+ contents: dict[str, str] = {}
257
+ for line in tree.stdout.splitlines():
258
+ meta, _, path = line.partition("\t")
259
+ fields = meta.split()
260
+ if path and len(fields) >= 3 and not _runtime_path(path):
261
+ contents[path] = fields[2]
262
+ changed = _fingerprint_changes(status.stdout, contents, include_untracked, untracked_filter)
263
+ if changed is None:
264
+ return ""
265
+ hashed = hash_worktree_paths(project_root, changed)
266
+ if hashed is None:
267
+ return ""
268
+ for path, sha in hashed:
269
+ contents[path] = sha
270
+ digest = hashlib.sha256()
271
+ for path in sorted(contents):
272
+ digest.update(path.encode())
273
+ digest.update(b"\0")
274
+ digest.update(contents[path].encode())
275
+ digest.update(b"\n")
276
+ return digest.hexdigest()
277
+
278
+
279
+ def _execute(argv: Sequence[str], cwd: str, timeout: float, deadline: Optional[float],
280
+ env: Optional[Mapping[str, str]] = None) -> dict[str, object]:
281
+ remaining = remaining_seconds(deadline)
282
+ if remaining is not None and remaining <= 0:
283
+ return {"status": "deadline_exceeded", "returncode": None, "stdout": "", "stderr": "",
284
+ "execution_started": False}
285
+ # When the entry deadline is tighter than the command timeout, expiry is
286
+ # unexecuted work (`incomplete`), never a product failure.
287
+ deadline_bounded = remaining is not None and remaining <= timeout
288
+ effective = min(timeout, remaining) if remaining is not None else timeout
289
+ child_env = command_environment(env)
290
+ try:
291
+ proc = subprocess.Popen(
292
+ list(argv), cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
293
+ env=child_env, start_new_session=os.name != "nt",
294
+ )
295
+ out, err = proc.communicate(timeout=max(effective, 0.01))
296
+ except subprocess.TimeoutExpired as exc:
297
+ return _timeout_result(proc, exc, deadline_bounded)
298
+ except OSError as exc:
299
+ return {"status": "spawn_error", "returncode": None, "stdout": "", "stderr": str(exc),
300
+ "execution_started": False}
301
+ return {"status": "ok", "returncode": proc.returncode, "stdout": out or "", "stderr": err or ""}
302
+
303
+
304
+ def _timeout_result(proc: subprocess.Popen, expired: subprocess.TimeoutExpired,
305
+ deadline_bounded: bool) -> dict[str, object]:
306
+ if os.name != "nt":
307
+ try:
308
+ os.killpg(proc.pid, signal.SIGKILL)
309
+ except ProcessLookupError:
310
+ pass # The process group already exited between timeout and kill.
311
+ else:
312
+ subprocess.run(("taskkill", "/F", "/T", "/PID", str(proc.pid)),
313
+ capture_output=True, timeout=2, check=False)
314
+ proc.kill()
315
+ try:
316
+ out, err = proc.communicate(timeout=1)
317
+ except subprocess.TimeoutExpired:
318
+ out, err = expired.stdout, expired.stderr
319
+ for pipe in (proc.stdout, proc.stderr):
320
+ if pipe is not None:
321
+ pipe.close()
322
+ def tail(value: object) -> str:
323
+ return (value.decode(errors="replace") if isinstance(value, bytes) else str(value or ""))[-4000:]
324
+ return {"status": "deadline_exceeded" if deadline_bounded else "timeout", "returncode": None,
325
+ "stdout": tail(out), "stderr": tail(err)}
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """L0 filesystem primitives: text reads and atomic replacement writes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Optional, Union
10
+
11
+ PathLike = Union[str, os.PathLike]
12
+
13
+
14
+ def read_text(path: PathLike) -> Optional[str]:
15
+ """Read UTF-8 text; a missing file is None, other IO errors propagate."""
16
+ try:
17
+ return Path(path).read_text(encoding="utf-8")
18
+ except FileNotFoundError:
19
+ return None
20
+
21
+
22
+ def write_text(path: PathLike, text: str) -> None:
23
+ """Write UTF-8 text in place (non-atomic convenience writer)."""
24
+ Path(path).write_text(text, encoding="utf-8")
25
+
26
+
27
+ def atomic_write_text(path: PathLike, text: str) -> None:
28
+ """Replace path with exact UTF-8 text via a temp file and os.replace.
29
+
30
+ On any failure the target keeps its previous content and the temp file is
31
+ removed before the original exception propagates. Disable native newline
32
+ translation so transaction snapshots also round-trip unchanged on Windows.
33
+ """
34
+ target = Path(path)
35
+ temporary = ""
36
+ try:
37
+ with tempfile.NamedTemporaryFile(
38
+ "w",
39
+ encoding="utf-8",
40
+ newline="",
41
+ dir=str(target.parent),
42
+ prefix=f".{target.name}.",
43
+ suffix=".tmp",
44
+ delete=False,
45
+ ) as handle:
46
+ temporary = handle.name
47
+ handle.write(text)
48
+ handle.flush()
49
+ os.fsync(handle.fileno())
50
+ os.replace(temporary, target)
51
+ except Exception:
52
+ if temporary:
53
+ try:
54
+ os.unlink(temporary)
55
+ except OSError:
56
+ pass # best effort: the original failure is what callers need
57
+ raise
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """L0 Git primitives: one implementation of the git CLI for every consumer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import subprocess
7
+ from typing import Mapping, Optional, Sequence
8
+
9
+ _STATUS_ARGV = ("status", "--porcelain=v1", "-z", "--untracked-files=all")
10
+ _STATUS_NO_UNTRACKED_ARGV = ("status", "--porcelain=v1", "-z", "--untracked-files=no")
11
+
12
+
13
+ class GitError(RuntimeError):
14
+ """Stable git failure carrying a machine code and the failing argv."""
15
+
16
+ def __init__(self, code: str, message: str, *, argv: Sequence[str] = (),
17
+ returncode: Optional[int] = None) -> None:
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.message = message
21
+ self.argv = tuple(argv)
22
+ self.returncode = returncode
23
+
24
+
25
+ def run_git(root: str, argv: Sequence[str], *, timeout: float = 30) -> str:
26
+ """Run git in root and return stdout; any failure raises GitError."""
27
+ try:
28
+ result = subprocess.run(
29
+ ("git", *argv), cwd=root, text=True, encoding="utf-8",
30
+ capture_output=True, check=False, timeout=timeout,
31
+ )
32
+ except OSError as exc:
33
+ raise GitError("git_unavailable", str(exc), argv=argv) from exc
34
+ except subprocess.TimeoutExpired as exc:
35
+ raise GitError("git_timeout", f"git timed out after {timeout}s", argv=argv) from exc
36
+ if result.returncode != 0:
37
+ message = result.stderr.strip() or "Git command failed"
38
+ raise GitError("git_error", message, argv=argv, returncode=result.returncode)
39
+ return result.stdout
40
+
41
+
42
+ def _status_name(code: str) -> str:
43
+ if code == "??":
44
+ return "untracked"
45
+ if "D" in code:
46
+ return "deleted"
47
+ if "A" in code:
48
+ return "added"
49
+ if "M" in code:
50
+ return "modified"
51
+ return "changed"
52
+
53
+
54
+ def changed_entries(root: str, *, include_untracked: bool = True) -> Mapping[str, str]:
55
+ """Working-tree changes keyed by relative path; renames also name the old path."""
56
+ argv = _STATUS_ARGV if include_untracked else _STATUS_NO_UNTRACKED_ARGV
57
+ records = run_git(root, argv).split("\0")
58
+ changes: dict[str, str] = {}
59
+ index = 0
60
+ while index < len(records) and records[index]:
61
+ record = records[index]
62
+ if len(record) < 4:
63
+ raise GitError("invalid_git_status", record, argv=argv)
64
+ code, path = record[:2], record[3:]
65
+ index += 1
66
+ if "R" in code or "C" in code:
67
+ # porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
68
+ if index >= len(records) or not records[index]:
69
+ raise GitError("invalid_git_status", f"{code} {path}", argv=argv)
70
+ old_path = records[index]
71
+ index += 1
72
+ changes[path] = _status_name(code)
73
+ changes[old_path] = "deleted"
74
+ continue
75
+ changes[path] = _status_name(code)
76
+ return changes
77
+
78
+
79
+ def changed_files(root: str, *, include_untracked: bool = True) -> tuple[str, ...]:
80
+ """Relative paths with uncommitted changes (working tree and index)."""
81
+ return tuple(changed_entries(root, include_untracked=include_untracked))
82
+
83
+
84
+ def tracked_files(root: str) -> tuple[str, ...]:
85
+ """Tracked relative paths; non-git projects read as an empty set."""
86
+ try:
87
+ output = run_git(root, ("ls-files",))
88
+ except GitError:
89
+ return ()
90
+ return tuple(
91
+ line.replace("\\", "/").strip() for line in output.splitlines() if line.strip()
92
+ )
@@ -38,7 +38,8 @@ KNOWN_EVENTS = (
38
38
  )
39
39
 
40
40
 
41
- def _log(msg: str) -> None:
41
+ def warn(msg: str) -> None:
42
+ """Write one diagnostic line to stderr; stdout stays reserved for protocol JSON."""
42
43
  sys.stderr.write(f"[cf-log] {msg}\n")
43
44
 
44
45
 
@@ -59,9 +60,14 @@ def _rotate_if_needed(project_root: str, path: str) -> None:
59
60
  now = datetime.now()
60
61
  month_dir = _archive_dir(project_root, now.strftime("%Y-%m"))
61
62
  os.makedirs(month_dir, exist_ok=True)
62
- target = os.path.join(
63
- month_dir, f"session-log-{now.strftime('%Y%m%dT%H%M%S')}.jsonl"
64
- )
63
+ stamp = now.strftime("%Y%m%dT%H%M%S")
64
+ target = os.path.join(month_dir, f"session-log-{stamp}.jsonl")
65
+ # Second-resolution names collide when a log rotates twice quickly; never
66
+ # overwrite an existing archive.
67
+ counter = 1
68
+ while os.path.exists(target):
69
+ counter += 1
70
+ target = os.path.join(month_dir, f"session-log-{stamp}-{counter}.jsonl")
65
71
  os.replace(path, target)
66
72
 
67
73
 
@@ -85,7 +91,7 @@ def append_event(project_root: str, event: str, data: dict, sid: str) -> bool:
85
91
  f.write(line + "\n")
86
92
  return True
87
93
  except Exception as exc:
88
- _log(f"append_event failed: {exc}")
94
+ warn(f"append_event failed: {exc}")
89
95
  return False
90
96
 
91
97
 
@@ -112,7 +118,7 @@ def _candidate_files(project_root: str, days: int) -> list:
112
118
  except ValueError:
113
119
  continue
114
120
  # keep a month if any part of it can fall inside the window
115
- if month_start >= cutoff.replace(day=1):
121
+ if month_start >= cutoff.replace(day=1, hour=0, minute=0, second=0, microsecond=0):
116
122
  month_dir = os.path.join(base, month)
117
123
  paths.extend(
118
124
  os.path.join(month_dir, name)
@@ -149,6 +155,6 @@ def read_events(project_root: str, days: int = 30, events: tuple = ()) -> list:
149
155
  continue
150
156
  out.append(item)
151
157
  except Exception as exc:
152
- _log(f"read_events skipped {path}: {exc}")
158
+ warn(f"read_events skipped {path}: {exc}")
153
159
  continue
154
160
  return out
@@ -0,0 +1,77 @@
1
+ """Shared Markdown primitives: frontmatter splitting and pipe-table headers.
2
+
3
+ Pure parsing only — no I/O, no schema knowledge. Feature modules keep their own
4
+ field aliases and value semantics.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Mapping, Optional, Sequence
11
+
12
+
13
+ _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
14
+ _SEPARATOR_CELL_RE = re.compile(r"^:?-{2,}:?$")
15
+
16
+
17
+ def parse_frontmatter(text: str) -> tuple[Mapping[str, object], str]:
18
+ """Split optional frontmatter into flat metadata + body.
19
+
20
+ Parses only flat `key: value` string pairs — enough for `description:` —
21
+ so the hook hot path never needs pyyaml for this. Content without
22
+ frontmatter returns ({}, text) unchanged.
23
+ """
24
+ if not isinstance(text, str):
25
+ return {}, text
26
+ match = _FRONTMATTER_RE.match(text)
27
+ if match is None:
28
+ return {}, text
29
+ meta: dict[str, object] = {}
30
+ for line in match.group(1).splitlines():
31
+ key, sep, value = line.partition(":")
32
+ if not sep:
33
+ continue
34
+ key = key.strip()
35
+ if key:
36
+ meta[key] = value.strip().strip('"').strip("'")
37
+ return meta, text[match.end():]
38
+
39
+
40
+ def frontmatter_block(text: str) -> Optional[str]:
41
+ """Return the raw frontmatter text (no delimiters), or None when absent."""
42
+ if not isinstance(text, str):
43
+ return None
44
+ match = _FRONTMATTER_RE.match(text)
45
+ return match.group(1) if match else None
46
+
47
+
48
+ def table_cells(line: str) -> tuple[str, ...]:
49
+ """Split one pipe-table line into trimmed cells; () for non-table lines."""
50
+ stripped = line.strip()
51
+ if not stripped.startswith("|"):
52
+ return ()
53
+ return tuple(cell.strip() for cell in stripped.strip("|").split("|"))
54
+
55
+
56
+ def is_separator_row(cells: Sequence[str]) -> bool:
57
+ """True for the `|---|:--:|` alignment row."""
58
+ return bool(cells) and all(_SEPARATOR_CELL_RE.match(cell.replace(" ", "")) for cell in cells)
59
+
60
+
61
+ def table_columns(lines: Sequence[str]) -> Mapping[str, int]:
62
+ """Map header names to column offsets; {} when no header row exists."""
63
+ for line in lines:
64
+ cells = table_cells(line)
65
+ if not cells or is_separator_row(cells):
66
+ continue
67
+ return {cell: index for index, cell in enumerate(cells)}
68
+ return {}
69
+
70
+
71
+ def column_index(header: Sequence[str], *names: str) -> int:
72
+ """Lowest header index containing any alias (case-insensitive), else -1."""
73
+ for index, cell in enumerate(header):
74
+ lowered = cell.lower()
75
+ if any(name.lower() in lowered for name in names):
76
+ return index
77
+ return -1
@@ -0,0 +1,26 @@
1
+ """Native runtime project discovery and all-or-nothing path validation."""
2
+ import os
3
+ from pathlib import Path
4
+ from codeflow.core.cf_patch import FileOperation
5
+
6
+
7
+ def project_root(cwd: str) -> str:
8
+ directory = Path(cwd).absolute()
9
+ for candidate in (directory, *directory.parents):
10
+ if (candidate / ".code-flow/config.yml").is_file():
11
+ return str(candidate)
12
+ return ""
13
+
14
+
15
+ def validated_paths(root: str, cwd: str, operations: tuple[FileOperation, ...]) -> tuple[tuple[str, bool], ...]:
16
+ paths: dict[str, bool] = {}
17
+ for operation in operations:
18
+ for path, deleted in ((operation.path, operation.kind in {"delete", "move"}),
19
+ (operation.destination, False)):
20
+ if not path:
21
+ continue
22
+ absolute = Path(os.path.abspath(os.path.join(cwd, path)))
23
+ if not absolute.is_relative_to(Path(root)) or not absolute.resolve().is_relative_to(Path(root).resolve()):
24
+ raise ValueError(f"patch path is outside project: {path}")
25
+ paths[str(absolute)] = deleted
26
+ return tuple(paths.items())
@@ -0,0 +1,33 @@
1
+ """Shared structured patch parser for native platform entries."""
2
+ from dataclasses import dataclass, replace
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class FileOperation:
7
+ kind: str
8
+ path: str
9
+ destination: str = ""
10
+
11
+
12
+ def parse_patch(command: str) -> tuple[FileOperation, ...]:
13
+ lines = command.splitlines()
14
+ if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch":
15
+ raise ValueError("patch must contain a complete Begin/End envelope")
16
+ operations: list[FileOperation] = []
17
+ headers = {"*** Add File: ": "add", "*** Update File: ": "update", "*** Delete File: ": "delete"}
18
+ for line in lines[1:-1]:
19
+ header = next((prefix for prefix in headers if line.startswith(prefix)), "")
20
+ if header:
21
+ path = line[len(header):]
22
+ if not path or "\x00" in path:
23
+ raise ValueError("patch file path is empty or invalid")
24
+ operations.append(FileOperation(headers[header], path))
25
+ elif line.startswith("*** Move to: "):
26
+ if not operations or operations[-1].kind != "update" or not line[13:]:
27
+ raise ValueError("Move to must follow an Update File")
28
+ operations[-1] = replace(operations[-1], kind="move", destination=line[13:])
29
+ elif not operations or (line and not line.startswith(("+", "-", " ", "@@", "*** End of File"))):
30
+ raise ValueError("unrecognized patch syntax")
31
+ if not operations:
32
+ raise ValueError("patch contains no file operations")
33
+ return tuple(operations)