@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,253 @@
1
+ #!/usr/bin/env python3
2
+ """Worktree isolation contract for acceptance checks.
3
+
4
+ Isolated checks receive a deterministic namespace so parallel worktrees never
5
+ share ports or databases. Shared checks stay serial by default: they may pass
6
+ `parallel_safe: true`, but the effective answer is always false because a
7
+ shared service cannot be partitioned across worktrees.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shlex
17
+ import socket
18
+ import subprocess
19
+ import time
20
+ from contextlib import contextmanager
21
+ from pathlib import Path
22
+ from typing import Iterator, Mapping, Optional
23
+ from uuid import uuid4
24
+
25
+ ENV_SCOPES = ("shared", "isolated")
26
+ _SAFE_COMPONENT = re.compile(r"[^a-z0-9]+")
27
+ _LOCK_RELATIVE = Path("code-flow/locks/shared-env.lock")
28
+ _LOCK_POLL_SECONDS = 0.05
29
+ DEFAULT_SHARED_LOCK_WAIT = 300.0
30
+ DEFAULT_SHARED_LOCK_STALE = 1800.0
31
+
32
+
33
+ class SharedEnvLockTimeout(TimeoutError):
34
+ """A shared environment stayed locked past the configured wait budget."""
35
+
36
+ def __init__(self, path: Path, waited: float) -> None:
37
+ super().__init__(f"shared environment lock timeout after {waited:.1f}s: {path}")
38
+ self.path = path
39
+ self.waited = waited
40
+
41
+
42
+ def env_scope(check: Mapping[str, object]) -> str:
43
+ value = check.get("env_scope", "shared")
44
+ return value if value in ENV_SCOPES else "shared"
45
+
46
+
47
+ def is_isolated(check: Mapping[str, object]) -> bool:
48
+ return env_scope(check) == "isolated"
49
+
50
+
51
+ def effective_parallel_safe(check: Mapping[str, object]) -> bool:
52
+ """Shared environments are never parallel-safe, regardless of the flag."""
53
+ if not is_isolated(check):
54
+ return False
55
+ declared = check.get("parallel_safe", False)
56
+ return declared is True
57
+
58
+
59
+ def worktree_identity(root: str, explicit: str = "") -> str:
60
+ for candidate in (explicit, os.environ.get("CF_WORKTREE_ID", "")):
61
+ if isinstance(candidate, str) and candidate.strip():
62
+ return candidate.strip()
63
+ path = Path(root).resolve()
64
+ digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:8]
65
+ return f"{path.name or 'worktree'}-{digest}"
66
+
67
+
68
+ def resolve_run_id(explicit: str = "") -> str:
69
+ for candidate in (explicit, os.environ.get("CF_RUN_ID", "")):
70
+ if isinstance(candidate, str) and candidate.strip():
71
+ return candidate.strip()
72
+ return uuid4().hex
73
+
74
+
75
+ def port_offset(worktree_id: str) -> int:
76
+ digest = hashlib.sha256(worktree_id.encode("utf-8")).hexdigest()
77
+ return 10000 + int(digest[:4], 16) % 40000
78
+
79
+
80
+ def db_suffix(worktree_id: str) -> str:
81
+ slug = _SAFE_COMPONENT.sub("_", worktree_id.strip().lower()).strip("_") or "worktree"
82
+ digest = hashlib.sha256(worktree_id.encode("utf-8")).hexdigest()[:6]
83
+ return f"_{slug}_{digest}"
84
+
85
+
86
+ def worktree_namespace(root: str, worktree_id: str = "", run_id: str = "", task_id: str = "") -> dict[str, str]:
87
+ """Stable per-worktree namespace injected into isolated child processes."""
88
+ identity = worktree_identity(root, worktree_id)
89
+ return {
90
+ "CF_WORKTREE_ID": identity,
91
+ "CF_RUN_ID": resolve_run_id(run_id),
92
+ "CF_TASK_ID": task_id or os.environ.get("CF_TASK_ID", ""),
93
+ "CF_PORT_OFFSET": str(port_offset(identity)),
94
+ "CF_DB_SUFFIX": db_suffix(identity),
95
+ }
96
+
97
+
98
+ def check_environment(
99
+ check: Mapping[str, object], root: str, namespace: Optional[Mapping[str, str]] = None
100
+ ) -> dict[str, str]:
101
+ """Environment overlay for a check: isolated only, shared is untouched."""
102
+ if not is_isolated(check):
103
+ return {}
104
+ resolved = dict(namespace) if namespace is not None else worktree_namespace(root)
105
+ return {key: str(value) for key, value in resolved.items()}
106
+
107
+
108
+ def _git_common_dir(root: str) -> Path:
109
+ """Directory shared by every worktree of the same repository."""
110
+ result = subprocess.run(
111
+ ("git", "rev-parse", "--git-common-dir"),
112
+ cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False,
113
+ )
114
+ if result.returncode == 0 and result.stdout.strip():
115
+ common = Path(result.stdout.strip())
116
+ return common if common.is_absolute() else (Path(root) / common).resolve()
117
+ return (Path(root) / ".code-flow").resolve()
118
+
119
+
120
+ def shared_env_lock_path(root: str) -> Path:
121
+ """Lock file lives in the git common dir so all worktrees contend for it."""
122
+ return _git_common_dir(root) / _LOCK_RELATIVE
123
+
124
+
125
+ def _lock_payload(token: str, run_id: str, worktree_id: str) -> dict[str, object]:
126
+ return {
127
+ "token": token,
128
+ "pid": os.getpid(),
129
+ "host": socket.gethostname(),
130
+ "created_at": time.time(),
131
+ "run_id": run_id,
132
+ "worktree_id": worktree_id,
133
+ }
134
+
135
+
136
+ def _read_lock(path: Path) -> Optional[dict[str, object]]:
137
+ try:
138
+ data = json.loads(path.read_text(encoding="utf-8"))
139
+ except (OSError, json.JSONDecodeError):
140
+ return None
141
+ return data if isinstance(data, dict) else None
142
+
143
+
144
+ def _lock_is_stale(path: Path, stale_after: float) -> bool:
145
+ """Age is authoritative; a dead local PID is stale before the timeout."""
146
+ try:
147
+ stat = path.stat()
148
+ except OSError:
149
+ return False
150
+ data = _read_lock(path)
151
+ if data is None:
152
+ # A writer may be between O_CREAT and its first write; only break files
153
+ # that stayed unreadable beyond the grace window.
154
+ grace = min(max(stale_after, 0.1), 1.0)
155
+ return time.time() - stat.st_mtime >= grace
156
+ if data.get("host") == socket.gethostname():
157
+ pid = data.get("pid")
158
+ if isinstance(pid, int) and pid > 0:
159
+ try:
160
+ os.kill(pid, 0)
161
+ except ProcessLookupError:
162
+ return True
163
+ except (PermissionError, OSError):
164
+ return False
165
+ # A live local owner is never stale purely because the run is long.
166
+ return False
167
+ created = data.get("created_at")
168
+ if isinstance(created, (int, float)) and time.time() - float(created) >= stale_after:
169
+ return True
170
+ return False
171
+
172
+
173
+ def _acquire_shared_lock(path: Path, wait: float, stale_after: float, token: str,
174
+ run_id: str, worktree_id: str) -> None:
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ deadline = time.monotonic() + wait
177
+ while True:
178
+ try:
179
+ descriptor = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
180
+ except FileExistsError:
181
+ if _lock_is_stale(path, stale_after):
182
+ try:
183
+ path.unlink()
184
+ except FileNotFoundError:
185
+ pass
186
+ continue
187
+ remaining = deadline - time.monotonic()
188
+ if remaining <= 0:
189
+ raise SharedEnvLockTimeout(path, wait) from None
190
+ time.sleep(min(_LOCK_POLL_SECONDS, remaining))
191
+ continue
192
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
193
+ handle.write(json.dumps(_lock_payload(token, run_id, worktree_id), ensure_ascii=False))
194
+ handle.flush()
195
+ os.fsync(handle.fileno())
196
+ return
197
+
198
+
199
+ def _release_shared_lock(path: Path, token: str) -> None:
200
+ data = _read_lock(path)
201
+ if not isinstance(data, dict) or data.get("token") != token:
202
+ return
203
+ try:
204
+ path.unlink()
205
+ except FileNotFoundError:
206
+ return
207
+
208
+
209
+ @contextmanager
210
+ def shared_env_lock(
211
+ root: str,
212
+ *,
213
+ wait: Optional[float] = None,
214
+ stale_after: Optional[float] = None,
215
+ run_id: str = "",
216
+ worktree_id: str = "",
217
+ ) -> Iterator[dict[str, object]]:
218
+ """Cross-worktree lock for shared (non-partitionable) environment checks.
219
+
220
+ The lock is a JSON sidecar in the git common directory with an owner token.
221
+ Wait and stale budgets are explicit so a crashed worktree cannot block the
222
+ batch forever, while a live owner keeps the lock until released.
223
+ """
224
+ path = shared_env_lock_path(root)
225
+ resolved_wait = DEFAULT_SHARED_LOCK_WAIT if wait is None else max(0.0, float(wait))
226
+ resolved_stale = DEFAULT_SHARED_LOCK_STALE if stale_after is None else max(0.1, float(stale_after))
227
+ token = uuid4().hex
228
+ identity = worktree_id or worktree_identity(root)
229
+ key = run_id or resolve_run_id()
230
+ _acquire_shared_lock(path, resolved_wait, resolved_stale, token, key, identity)
231
+ try:
232
+ yield {"path": str(path), "token": token, "run_id": key, "worktree_id": identity}
233
+ finally:
234
+ _release_shared_lock(path, token)
235
+
236
+
237
+ def preflight_commands(check: Mapping[str, object]) -> list[list[str]]:
238
+ """Normalize preflight entries (argv array or shell words) to argv arrays."""
239
+ raw = check.get("preflight", [])
240
+ if not isinstance(raw, list):
241
+ raise ValueError("invalid check preflight: expected list")
242
+ commands: list[list[str]] = []
243
+ for entry in raw:
244
+ if isinstance(entry, str):
245
+ argv = shlex.split(entry)
246
+ elif isinstance(entry, list) and all(isinstance(token, str) for token in entry):
247
+ argv = list(entry)
248
+ else:
249
+ raise ValueError("invalid check preflight entry: expected argv array or command string")
250
+ if not argv or any(not token for token in argv):
251
+ raise ValueError("invalid check preflight entry: empty argv")
252
+ commands.append(argv)
253
+ return commands