@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,1719 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- """Persisted Spec Context schema, binding and confirmation commands."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- from dataclasses import dataclass, replace
8
- from datetime import datetime, timezone
9
- from fnmatch import fnmatchcase
10
- import hashlib
11
- import json
12
- import os
13
- from pathlib import Path
14
- import subprocess
2
+ """Path-frozen entry shim: implementation lives in codeflow.cli.cf_spec_context."""
15
3
  import sys
16
- import tempfile
17
- import time
18
- from typing import IO, Mapping, Optional, Sequence
19
-
20
- from cf_spec_metadata import SpecHashes, SpecMetadata, SpecRule, load_spec_metadata
21
- from cf_spec_resolver import SpecCandidate, resolve_candidates
22
- from cf_core import phase_timing
23
-
24
-
25
- CONTEXT_VERSION = 1
26
- STAGE_STATUSES = frozenset(
27
- ("pending", "applied", "not_applicable", "waived", "verified", "stale", "conflict", "unverified")
28
- )
29
- DECISION_KINDS = frozenset(("not_applicable", "waived", "manual_verification"))
30
- _AGENT_IDENTITIES = frozenset(("agent", "assistant", "codex", "claude", "opencode", "costrict"))
31
- _FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
32
- _PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
33
- _PERSISTENT_HASH_PATHS: set[str] = set()
34
- _MAX_HASH_CACHE_ENTRIES = 4096
35
- ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
36
- DEFAULT_ACTIVE_EXCLUDES = (
37
- ".code-flow/tasks/*",
38
- ".code-flow/specs/_session/*",
39
- ".code-flow/migrations/*",
40
- ".code-flow/.*state*",
41
- ".code-flow/.*cache*",
42
- ".code-flow/.session-log.jsonl",
43
- ".code-flow/.active-task.json",
44
- ".code-flow/.active-task.lock",
45
- ".code-flow/worktrees/*",
46
- )
47
-
48
-
49
- class ContextError(ValueError):
50
- """A stable context error suitable for JSON CLI output."""
51
-
52
- def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
53
- self.code = code
54
- self.field = field
55
- self.message = message
56
- self.path = path
57
- location = f"{path}: " if path else ""
58
- super().__init__(f"{location}{code} {field}: {message}")
59
-
60
- def to_dict(self) -> dict[str, str]:
61
- return {"code": self.code, "field": self.field, "message": self.message, "path": self.path}
62
-
63
-
64
- @dataclass(frozen=True)
65
- class ContextSource:
66
- type: str
67
- ref: str
68
-
69
-
70
- @dataclass(frozen=True)
71
- class Decision:
72
- kind: str
73
- reason: str
74
- confirmed_by: str
75
- confirmed_at: str
76
- source: str
77
- expires_at: Optional[str]
78
-
79
-
80
- @dataclass(frozen=True)
81
- class ArtifactRef:
82
- artifact: str
83
- section_id: str
84
- item_id: str
85
- artifact_sha256: str
86
-
87
-
88
- @dataclass(frozen=True)
89
- class RuleStageStatus:
90
- status: str
91
- refs: tuple[ArtifactRef, ...]
92
- decision: Optional[Decision]
93
- evidence: tuple[Mapping[str, object], ...]
94
-
95
-
96
- @dataclass(frozen=True)
97
- class RuleBinding:
98
- ref: str
99
- summary: str
100
- text_sha256: str
101
- enforcement: str
102
- verifier_ref: str
103
- stage_status: Mapping[str, RuleStageStatus]
104
-
105
-
106
- @dataclass(frozen=True)
107
- class SpecBinding:
108
- spec_id: str
109
- path: str
110
- hashes: SpecHashes
111
- selected_by: str
112
- reason: str
113
- enforcement: str
114
- stages: tuple[str, ...]
115
- rules: tuple[RuleBinding, ...]
116
- status: str = "active"
117
-
118
-
119
- @dataclass(frozen=True)
120
- class SpecContext:
121
- version: int
122
- task: str
123
- enforcement: str
124
- updated_at: str
125
- sources: tuple[ContextSource, ...]
126
- bindings: tuple[SpecBinding, ...]
127
-
128
-
129
- @dataclass(frozen=True)
130
- class BindingInput:
131
- candidate: SpecCandidate
132
- selected_by: str
133
- reason: str
134
-
135
-
136
- @dataclass(frozen=True)
137
- class DriftChange:
138
- kind: str
139
- spec_id: str
140
- rule_ref: str
141
- old_hash: str
142
- new_hash: str
143
- stages: tuple[str, ...]
144
-
145
-
146
- @dataclass(frozen=True)
147
- class DriftResult:
148
- context: SpecContext
149
- changes: tuple[DriftChange, ...]
150
-
151
-
152
- @dataclass(frozen=True)
153
- class PathSnapshot:
154
- status: str
155
- content_sha256: str
156
-
157
-
158
- @dataclass(frozen=True)
159
- class ActiveBaseline:
160
- head: Optional[str]
161
- captured_at: str
162
- preexisting_changes: Mapping[str, PathSnapshot]
163
- # head is frozen at start and never rewritten: task scope is the union of
164
- # everything committed since head plus all working-tree changes.
165
- # last_seen_head is observation-only (diagnostics), never scope input.
166
- last_seen_head: Optional[str] = None
167
-
168
-
169
- @dataclass(frozen=True)
170
- class ActiveTask:
171
- version: int
172
- task_dir: str
173
- task_id: str
174
- status: str
175
- context_sha256: str
176
- baseline: ActiveBaseline
177
- owned_paths: tuple[str, ...]
178
- excluded_paths: tuple[str, ...]
179
-
180
-
181
- @dataclass(frozen=True)
182
- class DoctorResult:
183
- action: str
184
- active: ActiveTask
185
-
186
-
187
- def _now() -> str:
188
- return datetime.now(timezone.utc).isoformat()
189
-
190
-
191
- def _atomic_text(target: Path, text: str) -> None:
192
- temporary = ""
193
- try:
194
- with tempfile.NamedTemporaryFile(
195
- "w",
196
- encoding="utf-8",
197
- dir=str(target.parent),
198
- prefix=f".{target.name}.",
199
- suffix=".tmp",
200
- delete=False,
201
- ) as handle:
202
- temporary = handle.name
203
- handle.write(text)
204
- handle.flush()
205
- os.fsync(handle.fileno())
206
- os.replace(temporary, target)
207
- except OSError as exc:
208
- if temporary and Path(temporary).exists():
209
- Path(temporary).unlink()
210
- raise ContextError("atomic_write_error", "file", str(exc), str(target)) from exc
211
-
212
-
213
- def _active_paths(root: str) -> tuple[Path, Path]:
214
- flow = Path(root) / ".code-flow"
215
- return flow / ".active-task.json", flow / ".active-task.lock"
216
-
217
-
218
- def _run_git(root: str, arguments: Sequence[str]) -> str:
219
- try:
220
- result = subprocess.run(
221
- ("git", *arguments), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
222
- )
223
- except OSError as exc:
224
- raise ContextError("git_unavailable", "git", str(exc), root) from exc
225
- if result.returncode != 0:
226
- message = result.stderr.strip() or "Git command failed"
227
- raise ContextError("git_error", "git", message, root)
228
- return result.stdout
229
-
230
-
231
- def _git_head(root: str) -> Optional[str]:
232
- try:
233
- return _run_git(root, ("rev-parse", "HEAD")).strip()
234
- except ContextError as exc:
235
- if "unknown revision" in exc.message or "ambiguous argument" in exc.message:
236
- return None
237
- raise
238
-
239
-
240
- def _status_name(code: str) -> str:
241
- if code == "??":
242
- return "untracked"
243
- if "D" in code:
244
- return "deleted"
245
- if "A" in code:
246
- return "added"
247
- if "M" in code:
248
- return "modified"
249
- return "changed"
250
-
251
-
252
- def _git_changes(root: str) -> Mapping[str, str]:
253
- output = _run_git(root, ("status", "--porcelain=v1", "-z", "--untracked-files=all"))
254
- records = output.split("\0")
255
- changes: dict[str, str] = {}
256
- index = 0
257
- while index < len(records) and records[index]:
258
- record = records[index]
259
- if len(record) < 4:
260
- raise ContextError("invalid_git_status", "git.status", record, root)
261
- code, path = record[:2], record[3:]
262
- index += 1
263
- if "R" in code or "C" in code:
264
- # porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
265
- if index >= len(records) or not records[index]:
266
- raise ContextError("invalid_git_status", "git.status", f"{code} {path}", root)
267
- old_path = records[index]
268
- index += 1
269
- changes[path] = _status_name(code)
270
- changes[old_path] = "deleted"
271
- continue
272
- changes[path] = _status_name(code)
273
- return changes
274
-
275
-
276
- def _is_excluded(path: str, patterns: Sequence[str]) -> bool:
277
- return any(fnmatchcase(path, pattern) for pattern in patterns)
278
-
279
-
280
- def _active_data(active: ActiveTask) -> dict[str, object]:
281
- changes = {
282
- path: {"status": item.status, "content_sha256": item.content_sha256}
283
- for path, item in sorted(active.baseline.preexisting_changes.items())
284
- }
285
- return {
286
- "version": active.version,
287
- "task_dir": active.task_dir,
288
- "task_id": active.task_id,
289
- "status": active.status,
290
- "context_sha256": active.context_sha256,
291
- "baseline": {
292
- "head": active.baseline.head,
293
- "last_seen_head": active.baseline.last_seen_head,
294
- "captured_at": active.baseline.captured_at,
295
- "preexisting_changes": changes,
296
- },
297
- "owned_paths": list(active.owned_paths),
298
- "excluded_paths": list(active.excluded_paths),
299
- }
300
-
301
-
302
- def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
303
- data = _mapping(value, "preexisting_changes[]", path)
304
- content_hash = data.get("content_sha256")
305
- return PathSnapshot(
306
- _string(data.get("status"), "status", path),
307
- content_hash if isinstance(content_hash, str) else "",
308
- )
309
-
310
-
311
- def _active_from_data(value: object, path: str) -> ActiveTask:
312
- data = _mapping(value, "active", path)
313
- baseline_data = _mapping(data.get("baseline"), "baseline", path)
314
- raw_changes = _mapping(
315
- baseline_data.get("preexisting_changes"), "baseline.preexisting_changes", path
316
- )
317
- version = data.get("version")
318
- head = baseline_data.get("head")
319
- if version != 1 or (head is not None and not isinstance(head, str)):
320
- raise ContextError("invalid_active_marker", "version/baseline.head", "schema 不受支持", path)
321
- status = _string(data.get("status"), "status", path)
322
- if status not in ACTIVE_STATUSES:
323
- raise ContextError("invalid_active_marker", "status", status, path)
324
- baseline = ActiveBaseline(
325
- head,
326
- _string(baseline_data.get("captured_at"), "captured_at", path),
327
- {key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
328
- baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
329
- )
330
- return ActiveTask(
331
- 1,
332
- _string(data.get("task_dir"), "task_dir", path),
333
- _string(data.get("task_id"), "task_id", path),
334
- status,
335
- _string(data.get("context_sha256"), "context_sha256", path),
336
- baseline,
337
- tuple(_string(item, "owned_paths[]", path) for item in _sequence(data.get("owned_paths"), "owned_paths", path)),
338
- tuple(_string(item, "excluded_paths[]", path) for item in _sequence(data.get("excluded_paths"), "excluded_paths", path)),
339
- )
340
-
341
-
342
- def save_active_task(root: str, active: ActiveTask) -> None:
343
- marker, unused_lock = _active_paths(root)
344
- del unused_lock
345
- marker.parent.mkdir(parents=True, exist_ok=True)
346
- _atomic_text(marker, json.dumps(_active_data(active), ensure_ascii=False, indent=2) + "\n")
347
-
348
-
349
- def load_active_task(root: str) -> ActiveTask:
350
- marker, unused_lock = _active_paths(root)
351
- del unused_lock
352
- try:
353
- value = json.loads(marker.read_text(encoding="utf-8"))
354
- return _active_from_data(value, str(marker))
355
- except ContextError:
356
- raise
357
- except (OSError, json.JSONDecodeError) as exc:
358
- raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
359
-
360
-
361
- def task_contract_digest(root: str, task_dir: str, task_id: str) -> str:
362
- """Digest of the active TASK's acceptance contract (Refs + Contract + Evidence).
363
-
364
- Two TASKs may share one Context hash while owning different scenarios; the
365
- digest separates their injection versions so switching TASKs re-injects.
366
- Returns "" when the TASK section cannot be located (fail-open to the
367
- legacy context-hash-only key rather than crashing the hook hot path).
368
- """
369
- try:
370
- files = sorted((Path(root) / task_dir).glob("*.md"))
371
- except OSError:
372
- return ""
373
- for path in files:
374
- if path.name.endswith((".prd.md", ".design.md")):
375
- continue
376
- try:
377
- text = path.read_text(encoding="utf-8")
378
- except (OSError, UnicodeError):
379
- continue
380
- start = text.find(f"## {task_id}:")
381
- if start == -1:
382
- continue
383
- end = text.find("\n## TASK-", start)
384
- section = text[start:] if end == -1 else text[start:end]
385
- chunks: list[str] = []
386
- for heading in ("Acceptance-Refs", "Acceptance Contract", "Acceptance Evidence"):
387
- index = section.find(heading)
388
- if index == -1:
389
- chunks.append(f"{heading}:missing")
390
- continue
391
- end = section.find("\n### ", index)
392
- chunks.append(section[index:] if end == -1 else section[index:end])
393
- return hashlib.sha256("\n".join(chunks).encode("utf-8")).hexdigest()
394
- return ""
395
-
396
-
397
- def injection_version(root: str, session_id: str, context_sha: str) -> str:
398
- """Version key for hook injection dedup.
399
-
400
- Covers session + task_dir + task_id + context hash + TASK contract digest,
401
- so a TASK switch (or an in-place contract edit) always re-injects while a
402
- repeated prompt on the same contract stays suppressed.
403
- """
404
- try:
405
- active = load_active_task(root)
406
- except (ContextError, OSError, ValueError):
407
- return f"none:{session_id}:{context_sha}"
408
- digest = task_contract_digest(root, active.task_dir, active.task_id)
409
- raw = "\n".join((session_id, active.task_dir, active.task_id, context_sha, digest))
410
- return hashlib.sha256(raw.encode("utf-8")).hexdigest()
411
-
412
-
413
- def resync_active_hash(root: str, task_dir: str, current_sha256: str) -> bool:
414
- """Re-sync the active marker's context hash after toolchain-initiated
415
- context edits (bind/decision/refresh) so they never surface as
416
- unrecoverable active_context_drift. No-op without an active marker or when
417
- the hash already matches; never raises on a missing marker."""
418
- marker = Path(root) / ".code-flow" / ".active-task.json"
419
- if not marker.exists():
420
- return False
421
- active = load_active_task(root)
422
- if active.context_sha256 == current_sha256:
423
- return False
424
- save_active_task(root, replace(active, context_sha256=current_sha256))
425
- return True
426
-
427
-
428
- def _find_root(task_dir: str) -> Optional[str]:
429
- """Walk up from a task dir to the project root that owns its marker."""
430
- current = Path(task_dir).resolve()
431
- for candidate in (current, *current.parents):
432
- if (candidate / ".code-flow" / ".active-task.json").exists():
433
- return str(candidate)
434
- return None
435
-
436
-
437
- def _resync_after_save(args: argparse.Namespace, context_path: Path) -> bool:
438
- """Post-save marker re-sync used by CLI commands that persist the context."""
439
- root = getattr(args, "root", "") or _find_root(str(context_path.parent)) or ""
440
- if not root:
441
- return False
442
- current = context_sha256(load_context(str(context_path)))
443
- return resync_active_hash(root, str(context_path.parent), current)
444
-
445
-
446
- def _lock_stale(lock: Path) -> bool:
447
- """A lock is stale when its file is unreadable, carries no valid PID, or the
448
- owning process is gone. A lock we cannot prove dead is never deleted (PID
449
- reuse is accepted as a rare race)."""
450
- try:
451
- data = json.loads(lock.read_text(encoding="utf-8"))
452
- except (OSError, json.JSONDecodeError):
453
- return True
454
- pid = data.get("pid")
455
- if not isinstance(pid, int) or pid <= 0:
456
- return True
457
- try:
458
- os.kill(pid, 0)
459
- except ProcessLookupError:
460
- return True
461
- except (PermissionError, OSError):
462
- return False
463
- return False
464
-
465
-
466
- def _acquire_active_lock(root: str) -> Path:
467
- marker, lock = _active_paths(root)
468
- lock.parent.mkdir(parents=True, exist_ok=True)
469
- descriptor = -1
470
- for _attempt in range(2):
471
- try:
472
- descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
473
- break
474
- except FileExistsError:
475
- if not _lock_stale(lock):
476
- raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from None
477
- try:
478
- lock.unlink()
479
- except OSError as exc:
480
- raise ContextError("active_lock_exists", "active.lock", f"残留 lock 清理失败: {exc}", str(lock)) from exc
481
- else:
482
- raise ContextError("active_lock_exists", "active.lock", "残留 lock 无法清理", str(lock))
483
- with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
484
- handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
485
- return lock
486
-
487
-
488
- def _release_active_lock(lock: Path) -> None:
489
- try:
490
- lock.unlink()
491
- except FileNotFoundError:
492
- return
493
-
494
-
495
- def _committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
496
- """Paths committed between the frozen baseline head and current HEAD.
497
-
498
- Returns {} when there is no baseline (pre-commit repo) or the range is
499
- unresolvable (rewritten/pruned history) — working-tree changes still
500
- flow through _business_changes, so scope never goes fully blind.
501
- """
502
- if not base_head:
503
- return {}
504
- try:
505
- output = _run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
506
- except ContextError:
507
- return {}
508
- names: dict[str, str] = {}
509
- tokens = output.split("\0")
510
- index = 0
511
- while index < len(tokens) and tokens[index]:
512
- status, index = tokens[index], index + 1
513
- code = status[:1]
514
- if code in ("R", "C"):
515
- if index + 1 >= len(tokens):
516
- break
517
- new_path, old_path = tokens[index], tokens[index + 1]
518
- index += 2
519
- if new_path:
520
- names[new_path] = "renamed"
521
- if old_path:
522
- names[old_path] = "deleted"
523
- continue
524
- if index >= len(tokens) or not tokens[index]:
525
- break
526
- names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
527
- index += 1
528
- return names
529
-
530
-
531
- def _business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
532
- return {
533
- path: status
534
- for path, status in _git_changes(root).items()
535
- if not _is_excluded(path, excludes)
536
- }
537
-
538
-
539
- def _new_active(
540
- root: str,
541
- task_dir: str,
542
- task_id: str,
543
- context_sha256: str,
544
- owned_preexisting: Sequence[str],
545
- ) -> ActiveTask:
546
- changes = _business_changes(root, DEFAULT_ACTIVE_EXCLUDES)
547
- owned = tuple(sorted(set(owned_preexisting)))
548
- unknown = tuple(path for path in owned if path not in changes)
549
- unowned = tuple(path for path in changes if path not in owned)
550
- if unknown:
551
- raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
552
- if unowned:
553
- raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
554
- # content_sha256 was a per-file hash but is never consumed by any gate (only
555
- # baseline.head is compared); keep the schema field empty so start never hashes
556
- # every uncommitted file.
557
- snapshots = {
558
- path: PathSnapshot(status, "") for path, status in changes.items()
559
- }
560
- head = _git_head(root)
561
- baseline = ActiveBaseline(head, _now(), snapshots, head)
562
- return ActiveTask(
563
- 1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
564
- )
565
-
566
-
567
- def start_active_task(
568
- root: str,
569
- task_dir: str,
570
- task_id: str,
571
- context_sha256: str,
572
- owned_preexisting: Sequence[str] = (),
573
- ) -> ActiveTask:
574
- marker, unused_lock = _active_paths(root)
575
- del unused_lock
576
- lock = _acquire_active_lock(root)
577
- try:
578
- if marker.exists():
579
- raise ContextError("active_exists", "active", "当前 worktree 已有 active TASK", str(marker))
580
- active = _new_active(root, task_dir, task_id, context_sha256, owned_preexisting)
581
- save_active_task(root, active)
582
- active = replace(active, status="active")
583
- save_active_task(root, active)
584
- return active
585
- finally:
586
- _release_active_lock(lock)
587
-
588
-
589
- def _transition_active(root: str, expected: Sequence[str], target: str) -> ActiveTask:
590
- lock = _acquire_active_lock(root)
591
- try:
592
- active = load_active_task(root)
593
- if active.status not in expected:
594
- raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
595
- current_head = _git_head(root)
596
- if active.baseline.head != current_head:
597
- # Mid-task commits are a normal workflow. The baseline head stays
598
- # frozen so committed work never drops out of scope; only the
599
- # observation field advances for diagnostics.
600
- active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
601
- updated = replace(active, status=target)
602
- save_active_task(root, updated)
603
- return updated
604
- finally:
605
- _release_active_lock(lock)
606
-
607
-
608
- def pause_active_task(root: str) -> ActiveTask:
609
- return _transition_active(root, ("active",), "paused")
610
-
611
-
612
- def block_active_task(root: str) -> ActiveTask:
613
- return _transition_active(root, ("active",), "blocked")
614
-
615
-
616
- def resume_active_task(root: str) -> ActiveTask:
617
- return _transition_active(root, ("paused", "blocked"), "active")
618
-
619
-
620
- def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
621
- changes = _business_changes(root, active.excluded_paths)
622
- committed = {
623
- path: status
624
- for path, status in _committed_since(root, active.baseline.head).items()
625
- if not _is_excluded(path, active.excluded_paths)
626
- }
627
- return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
628
-
629
-
630
- def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
631
- if not gate_passed:
632
- raise ContextError("gate_failed", "gate", "Done Gate 未通过", root)
633
- completed = _transition_active(root, ("active",), "completed")
634
- marker, unused_lock = _active_paths(root)
635
- del unused_lock
636
- try:
637
- marker.unlink()
638
- except OSError as exc:
639
- raise ContextError("active_cleanup_failed", "active", str(exc), str(marker)) from exc
640
- return completed
641
-
642
-
643
- def doctor_active_task(
644
- root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
645
- ) -> DoctorResult:
646
- marker, lock = _active_paths(root)
647
- try:
648
- active = load_active_task(root)
649
- except ContextError as exc:
650
- raise ContextError("recovery_required", "active", exc.message, str(marker)) from exc
651
- if abandon:
652
- marker.unlink()
653
- _release_active_lock(lock)
654
- return DoctorResult("abandoned", replace(active, status="completed"))
655
- if resync:
656
- # Self-heal: the context file is authoritative and the marker hash is a
657
- # cache. Accept the current context when the marker is the only
658
- # inconsistency; bind/decision/refresh already re-sync automatically,
659
- # so this covers legacy markers and manual context edits.
660
- repaired = replace(active, status="active", context_sha256=expected_context_sha256)
661
- save_active_task(root, repaired)
662
- _release_active_lock(lock)
663
- return DoctorResult("resynced", repaired)
664
- proven = active.context_sha256 == expected_context_sha256
665
- proven = proven and active.baseline.head == _git_head(root)
666
- if active.status != "activating" or not proven:
667
- raise ContextError("recovery_required", "active", "状态或 hash 无法证明可自动恢复", str(marker))
668
- repaired = replace(active, status="active")
669
- save_active_task(root, repaired)
670
- _release_active_lock(lock)
671
- return DoctorResult("resumed", repaired)
672
-
673
-
674
- def _mapping(value: object, field: str, path: str) -> Mapping[str, object]:
675
- if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
676
- raise ContextError("invalid_context", field, "必须是字符串 key mapping", path)
677
- return value
678
-
679
-
680
- def _sequence(value: object, field: str, path: str) -> Sequence[object]:
681
- if not isinstance(value, list):
682
- raise ContextError("invalid_context", field, "必须是列表", path)
683
- return value
684
-
685
-
686
- def _string(value: object, field: str, path: str) -> str:
687
- if not isinstance(value, str) or not value.strip():
688
- raise ContextError("invalid_context", field, "必须是非空字符串", path)
689
- return value.strip()
690
-
691
-
692
- def _decision_string(value: object, field: str, path: str) -> str:
693
- if not isinstance(value, str) or not value.strip():
694
- raise ContextError("invalid_decision", field, "必须是非空字符串", path)
695
- return value.strip()
696
-
697
-
698
- def _timestamp(value: str, field: str) -> None:
699
- try:
700
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
701
- except ValueError as exc:
702
- raise ContextError("invalid_decision", field, "必须是 RFC3339 时间") from exc
703
- if parsed.tzinfo is None:
704
- raise ContextError("invalid_decision", field, "必须包含时区")
705
-
706
-
707
- def _validate_decision(decision: Decision) -> None:
708
- if decision.kind not in DECISION_KINDS:
709
- raise ContextError("invalid_decision", "decision.kind", f"不支持 {decision.kind!r}")
710
- for field in ("reason", "confirmed_by", "confirmed_at", "source"):
711
- if not getattr(decision, field).strip():
712
- raise ContextError("invalid_decision", f"decision.{field}", "必须是非空字符串")
713
- identity = decision.confirmed_by.lower().split(":", 1)[0]
714
- if identity in _AGENT_IDENTITIES:
715
- raise ContextError("agent_confirmation_forbidden", "decision.confirmed_by", "Agent 不能自行确认")
716
- _timestamp(decision.confirmed_at, "decision.confirmed_at")
717
- if decision.kind == "waived" and not decision.expires_at:
718
- raise ContextError("invalid_decision", "decision.expires_at", "waived 必须有失效时间")
719
- if decision.expires_at:
720
- _timestamp(decision.expires_at, "decision.expires_at")
721
-
722
-
723
- def _decision_from_data(value: object, path: str) -> Optional[Decision]:
724
- if value is None:
725
- return None
726
- data = _mapping(value, "decision", path)
727
- expires = data.get("expires_at")
728
- if expires is not None and not isinstance(expires, str):
729
- raise ContextError("invalid_decision", "decision.expires_at", "必须是字符串或 null", path)
730
- decision = Decision(
731
- _decision_string(data.get("kind"), "decision.kind", path),
732
- _decision_string(data.get("reason"), "decision.reason", path),
733
- _decision_string(data.get("confirmed_by"), "decision.confirmed_by", path),
734
- _decision_string(data.get("confirmed_at"), "decision.confirmed_at", path),
735
- _decision_string(data.get("source"), "decision.source", path),
736
- expires,
737
- )
738
- _validate_decision(decision)
739
- return decision
740
-
741
-
742
- def _decision_data(decision: Optional[Decision]) -> Optional[dict[str, object]]:
743
- if decision is None:
744
- return None
745
- return {
746
- "kind": decision.kind,
747
- "reason": decision.reason,
748
- "confirmed_by": decision.confirmed_by,
749
- "confirmed_at": decision.confirmed_at,
750
- "source": decision.source,
751
- "expires_at": decision.expires_at,
752
- }
753
-
754
-
755
- def _artifact_from_data(value: object, path: str) -> ArtifactRef:
756
- data = _mapping(value, "refs[]", path)
757
- return ArtifactRef(
758
- _string(data.get("artifact"), "refs[].artifact", path),
759
- _string(data.get("section_id"), "refs[].section_id", path),
760
- _string(data.get("item_id"), "refs[].item_id", path),
761
- _string(data.get("artifact_sha256"), "refs[].artifact_sha256", path),
762
- )
763
-
764
-
765
- def _artifact_data(reference: ArtifactRef) -> dict[str, str]:
766
- return {
767
- "artifact": reference.artifact,
768
- "section_id": reference.section_id,
769
- "item_id": reference.item_id,
770
- "artifact_sha256": reference.artifact_sha256,
771
- }
772
-
773
-
774
- def _stage_from_data(value: object, field: str, path: str) -> RuleStageStatus:
775
- data = _mapping(value, field, path)
776
- status = _string(data.get("status"), f"{field}.status", path)
777
- if status not in STAGE_STATUSES:
778
- raise ContextError("invalid_context", f"{field}.status", f"不支持 {status!r}", path)
779
- refs = tuple(_artifact_from_data(item, path) for item in _sequence(data.get("refs", []), f"{field}.refs", path))
780
- evidence = tuple(_mapping(item, f"{field}.evidence[]", path) for item in _sequence(data.get("evidence", []), f"{field}.evidence", path))
781
- decision = _decision_from_data(data.get("decision"), path)
782
- if status == "applied" and not refs:
783
- raise ContextError("invalid_context", f"{field}.refs", "applied 必须有 artifact ref", path)
784
- if status in ("not_applicable", "waived") and decision is None:
785
- raise ContextError("invalid_context", f"{field}.decision", f"{status} 必须有确认记录", path)
786
- return RuleStageStatus(status, refs, decision, evidence)
787
-
788
-
789
- def _stage_data(status: RuleStageStatus) -> dict[str, object]:
790
- return {
791
- "status": status.status,
792
- "refs": [_artifact_data(item) for item in status.refs],
793
- "decision": _decision_data(status.decision),
794
- "evidence": [dict(item) for item in status.evidence],
795
- }
796
-
797
-
798
- def _rule_from_data(value: object, path: str) -> RuleBinding:
799
- data = _mapping(value, "rules[]", path)
800
- stages = _mapping(data.get("stage_status"), "rules[].stage_status", path)
801
- parsed = {stage: _stage_from_data(item, f"stage_status.{stage}", path) for stage, item in stages.items()}
802
- return RuleBinding(
803
- _string(data.get("ref"), "rules[].ref", path),
804
- _string(data.get("summary"), "rules[].summary", path),
805
- _string(data.get("text_sha256"), "rules[].text_sha256", path),
806
- _string(data.get("enforcement"), "rules[].enforcement", path),
807
- _string(data.get("verifier_ref"), "rules[].verifier_ref", path),
808
- parsed,
809
- )
810
-
811
-
812
- def _rule_data(rule: RuleBinding) -> dict[str, object]:
813
- return {
814
- "ref": rule.ref,
815
- "summary": rule.summary,
816
- "text_sha256": rule.text_sha256,
817
- "enforcement": rule.enforcement,
818
- "verifier_ref": rule.verifier_ref,
819
- "stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
820
- }
821
-
822
-
823
- def _hashes_from_data(value: object, path: str) -> SpecHashes:
824
- data = _mapping(value, "hashes", path)
825
- return SpecHashes(
826
- _string(data.get("file_sha256"), "hashes.file_sha256", path),
827
- _string(data.get("metadata_sha256"), "hashes.metadata_sha256", path),
828
- _string(data.get("rules_sha256"), "hashes.rules_sha256", path),
829
- )
830
-
831
-
832
- def _binding_from_data(value: object, path: str) -> SpecBinding:
833
- data = _mapping(value, "bindings[]", path)
834
- stages = tuple(_string(item, "bindings[].stages[]", path) for item in _sequence(data.get("stages"), "bindings[].stages", path))
835
- rules = tuple(_rule_from_data(item, path) for item in _sequence(data.get("rules"), "bindings[].rules", path))
836
- return SpecBinding(
837
- _string(data.get("spec_id"), "bindings[].spec_id", path),
838
- _string(data.get("path"), "bindings[].path", path),
839
- _hashes_from_data(data.get("hashes"), path),
840
- _string(data.get("selected_by"), "bindings[].selected_by", path),
841
- _string(data.get("reason"), "bindings[].reason", path),
842
- _string(data.get("enforcement"), "bindings[].enforcement", path),
843
- stages,
844
- rules,
845
- _string(data.get("status", "active"), "bindings[].status", path),
846
- )
847
-
848
-
849
- def _binding_data(binding: SpecBinding) -> dict[str, object]:
850
- return {
851
- "spec_id": binding.spec_id,
852
- "path": binding.path,
853
- "status": binding.status,
854
- "hashes": {
855
- "file_sha256": binding.hashes.file_sha256,
856
- "metadata_sha256": binding.hashes.metadata_sha256,
857
- "rules_sha256": binding.hashes.rules_sha256,
858
- },
859
- "selected_by": binding.selected_by,
860
- "reason": binding.reason,
861
- "enforcement": binding.enforcement,
862
- "stages": list(binding.stages),
863
- "rules": [_rule_data(rule) for rule in binding.rules],
864
- }
865
-
866
-
867
- def context_to_data(context: SpecContext) -> dict[str, object]:
868
- return {
869
- "version": context.version,
870
- "task": context.task,
871
- "enforcement": context.enforcement,
872
- "updated_at": context.updated_at,
873
- "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
874
- "bindings": [_binding_data(binding) for binding in context.bindings],
875
- }
876
-
877
-
878
- def context_to_identity(context: SpecContext) -> dict[str, object]:
879
- """Stable identity projection for marker drift detection.
880
-
881
- Excludes volatile runtime state (`updated_at`, `stage_status` including
882
- evidence timestamps) so the active marker only invalidates when the bound
883
- rules themselves change (rule/spec text, bindings, sources).
884
- """
885
- return {
886
- "version": context.version,
887
- "task": context.task,
888
- "enforcement": context.enforcement,
889
- "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
890
- "bindings": [
891
- {
892
- "spec_id": binding.spec_id,
893
- "path": binding.path,
894
- "status": binding.status,
895
- "hashes": {
896
- "file_sha256": binding.hashes.file_sha256,
897
- "metadata_sha256": binding.hashes.metadata_sha256,
898
- "rules_sha256": binding.hashes.rules_sha256,
899
- },
900
- "selected_by": binding.selected_by,
901
- "reason": binding.reason,
902
- "enforcement": binding.enforcement,
903
- "stages": list(binding.stages),
904
- "rules": [
905
- {
906
- "ref": rule.ref,
907
- "summary": rule.summary,
908
- "text_sha256": rule.text_sha256,
909
- "enforcement": rule.enforcement,
910
- "verifier_ref": rule.verifier_ref,
911
- }
912
- for rule in binding.rules
913
- ],
914
- }
915
- for binding in context.bindings
916
- ],
917
- }
918
-
919
-
920
- def context_sha256(context: SpecContext) -> str:
921
- """Deterministic identity hash binding the active marker to the bound rules.
922
-
923
- Computed from the stable identity projection (never volatile runtime state),
924
- so evidence timestamps and stage statuses cannot drift the marker.
925
- """
926
- import yaml
927
- data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
928
- return hashlib.sha256(data).hexdigest()
929
-
930
-
931
- def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
932
- if not task.strip():
933
- raise ContextError("invalid_context", "task", "必须是非空字符串")
934
- parsed = tuple(ContextSource(_string(kind, "sources[].type", ""), _string(ref, "sources[].ref", "")) for kind, ref in sources)
935
- return SpecContext(CONTEXT_VERSION, task.strip(), "required", _now(), parsed, ())
936
-
937
-
938
- def load_context(path: str) -> SpecContext:
939
- import yaml
940
- try:
941
- loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
942
- except (OSError, UnicodeError, yaml.YAMLError) as exc:
943
- raise ContextError("context_read_error", "file", str(exc), path) from exc
944
- data = _mapping(loaded, "context", path)
945
- version = data.get("version")
946
- if version != CONTEXT_VERSION:
947
- raise ContextError("unsupported_version", "version", f"仅支持 {CONTEXT_VERSION},收到 {version!r}", path)
948
- source_values = _sequence(data.get("sources", []), "sources", path)
949
- sources = tuple(
950
- ContextSource(
951
- _string(_mapping(item, "sources[]", path).get("type"), "sources[].type", path),
952
- _string(_mapping(item, "sources[]", path).get("ref"), "sources[].ref", path),
953
- )
954
- for item in source_values
955
- )
956
- bindings = tuple(_binding_from_data(item, path) for item in _sequence(data.get("bindings", []), "bindings", path))
957
- return SpecContext(
958
- CONTEXT_VERSION,
959
- _string(data.get("task"), "task", path),
960
- _string(data.get("enforcement"), "enforcement", path),
961
- _string(data.get("updated_at"), "updated_at", path),
962
- sources,
963
- bindings,
964
- )
965
-
966
-
967
- def save_context(path: str, context: SpecContext) -> None:
968
- import yaml
969
- target = Path(path)
970
- target.parent.mkdir(parents=True, exist_ok=True)
971
- text = yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
972
- try:
973
- _atomic_text(target, text)
974
- except ContextError as exc:
975
- raise ContextError("context_write_error", "file", str(exc), path) from exc
976
-
977
-
978
- def _binding_from_input(selection: BindingInput) -> SpecBinding:
979
- if not selection.selected_by.strip() or not selection.reason.strip():
980
- raise ContextError("invalid_binding", "selection", "selected_by/reason 必须非空")
981
- candidate = selection.candidate
982
- verifier_by_rule = {item.rule: item for item in candidate.metadata.verifiers}
983
- rules: list[RuleBinding] = []
984
- for rule in candidate.metadata.rules:
985
- if rule.enforcement == "informational":
986
- continue
987
- enforcement = "advisory" if candidate.metadata.enforcement == "advisory" else rule.enforcement
988
- statuses = {
989
- stage: RuleStageStatus("pending", (), None, ()) for stage in candidate.metadata.stages
990
- }
991
- verifier = verifier_by_rule.get(rule.ref)
992
- verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
993
- rules.append(RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses))
994
- return SpecBinding(
995
- candidate.spec_id,
996
- candidate.path,
997
- candidate.metadata.hashes,
998
- selection.selected_by.strip(),
999
- selection.reason.strip(),
1000
- candidate.metadata.enforcement,
1001
- candidate.metadata.stages,
1002
- tuple(rules),
1003
- )
1004
-
1005
-
1006
- def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
1007
- enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
1008
- statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
1009
- has_verifier = any(item.rule == rule.ref for item in metadata.verifiers)
1010
- verifier_ref = f"{spec_id}#{rule.ref}" if has_verifier else "advisory:none"
1011
- return RuleBinding(rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses)
1012
-
1013
-
1014
- def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
1015
- bindings = {binding.spec_id: binding for binding in context.bindings}
1016
- for selection in selections:
1017
- incoming = _binding_from_input(selection)
1018
- existing = bindings.get(incoming.spec_id)
1019
- if existing is not None and existing.hashes != incoming.hashes:
1020
- raise ContextError(
1021
- "stale_binding",
1022
- "selection",
1023
- f"{incoming.spec_id} 已绑定版本发生变化,请先 refresh",
1024
- )
1025
- if existing is not None:
1026
- incoming = replace(
1027
- existing,
1028
- selected_by=incoming.selected_by,
1029
- reason=incoming.reason,
1030
- enforcement=incoming.enforcement,
1031
- stages=incoming.stages,
1032
- )
1033
- bindings[incoming.spec_id] = incoming
1034
- ordered = tuple(bindings[key] for key in sorted(bindings))
1035
- if ordered == context.bindings:
1036
- return context
1037
- return replace(context, updated_at=_now(), bindings=ordered)
1038
-
1039
-
1040
- def refresh_missing_specs(context: SpecContext, root: str) -> SpecContext:
1041
- spec_root = Path(root) / ".code-flow" / "specs"
1042
- bindings: list[SpecBinding] = []
1043
- for binding in context.bindings:
1044
- if (spec_root / binding.path).is_file():
1045
- bindings.append(binding)
1046
- continue
1047
- rules = []
1048
- for rule in binding.rules:
1049
- stages = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
1050
- rules.append(replace(rule, stage_status=stages))
1051
- bindings.append(replace(binding, status="missing", rules=tuple(rules)))
1052
- return replace(context, updated_at=_now(), bindings=tuple(bindings))
1053
-
1054
-
1055
- def _stale_rule(rule: RuleBinding) -> RuleBinding:
1056
- statuses = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
1057
- return replace(rule, stage_status=statuses)
1058
-
1059
-
1060
- def _removed_rule(rule: RuleBinding) -> RuleBinding:
1061
- statuses = {
1062
- stage: status
1063
- if status.status in ("not_applicable", "waived") and status.decision is not None
1064
- else replace(status, status="stale")
1065
- for stage, status in rule.stage_status.items()
1066
- }
1067
- return replace(rule, stage_status=statuses)
1068
-
1069
-
1070
- def _file_sha256(path: Path) -> str:
1071
- try:
1072
- stat = path.stat()
1073
- key = str(path)
1074
- cached = _FILE_HASH_CACHE.get(key)
1075
- signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
1076
- if cached is not None and cached[:3] == signature:
1077
- return cached[3]
1078
- cache_path = next(
1079
- (parent / ".artifact-hash-cache.json" for parent in path.parents if parent.name == ".code-flow"),
1080
- None,
1081
- )
1082
- cache_key = str(cache_path) if cache_path is not None else ""
1083
- if cache_key and cache_key not in _PERSISTENT_HASH_PATHS:
1084
- try:
1085
- loaded = json.loads(cache_path.read_text(encoding="utf-8"))
1086
- if isinstance(loaded, dict):
1087
- _PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
1088
- except (OSError, ValueError):
1089
- pass
1090
- _PERSISTENT_HASH_PATHS.add(cache_key)
1091
- persistent = _PERSISTENT_HASH_CACHE.get(key)
1092
- if (
1093
- isinstance(persistent, dict)
1094
- and persistent.get("mtime_ns") == stat.st_mtime_ns
1095
- and persistent.get("size") == stat.st_size
1096
- and persistent.get("ino") == stat.st_ino
1097
- ):
1098
- digest = persistent.get("sha256")
1099
- if isinstance(digest, str) and digest:
1100
- _FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
1101
- return digest
1102
- digest = hashlib.sha256(path.read_bytes()).hexdigest()
1103
- _FILE_HASH_CACHE[key] = (signature[0], signature[1], signature[2], digest)
1104
- if cache_key:
1105
- _PERSISTENT_HASH_CACHE[key] = {
1106
- "mtime_ns": stat.st_mtime_ns,
1107
- "size": stat.st_size,
1108
- "ino": stat.st_ino,
1109
- "sha256": digest,
1110
- }
1111
- while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
1112
- _PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
1113
- try:
1114
- temporary = cache_path.with_suffix(".json.tmp")
1115
- temporary.write_text(
1116
- json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True),
1117
- encoding="utf-8",
1118
- )
1119
- temporary.replace(cache_path)
1120
- except OSError:
1121
- pass
1122
- return digest
1123
- except OSError as exc:
1124
- raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
1125
-
1126
-
1127
- def _refresh_artifacts(
1128
- spec_id: str, rule: RuleBinding, artifact_root: Path
1129
- ) -> tuple[RuleBinding, tuple[DriftChange, ...]]:
1130
- statuses = dict(rule.stage_status)
1131
- changes: list[DriftChange] = []
1132
- for stage, status in rule.stage_status.items():
1133
- for reference in status.refs:
1134
- path = artifact_root / reference.artifact
1135
- current = _file_sha256(path) if path.is_file() else "missing"
1136
- if current == reference.artifact_sha256:
1137
- continue
1138
- if status.status == "applied" and current != "missing":
1139
- statuses[stage] = replace(
1140
- status,
1141
- refs=tuple(
1142
- replace(item, artifact_sha256=current) if item is reference else item
1143
- for item in status.refs
1144
- ),
1145
- )
1146
- else:
1147
- statuses[stage] = replace(status, status="stale")
1148
- changes.append(
1149
- DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
1150
- )
1151
- break
1152
- return replace(rule, stage_status=statuses), tuple(changes)
1153
-
1154
-
1155
- def _refresh_existing_rule(
1156
- binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
1157
- ) -> tuple[RuleBinding, Optional[DriftChange]]:
1158
- enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
1159
- has_verifier = any(item.rule == current.ref for item in metadata.verifiers)
1160
- verifier_ref = f"{binding.spec_id}#{current.ref}" if has_verifier else "advisory:none"
1161
- updated = replace(
1162
- existing,
1163
- summary=current.text,
1164
- text_sha256=current.text_sha256,
1165
- enforcement=enforcement,
1166
- verifier_ref=verifier_ref,
1167
- )
1168
- if existing.text_sha256 == current.text_sha256:
1169
- return updated, None
1170
- change = DriftChange(
1171
- "rule_changed", binding.spec_id, current.ref, existing.text_sha256, current.text_sha256, tuple(existing.stage_status)
1172
- )
1173
- return _stale_rule(updated), change
1174
-
1175
-
1176
- def _refresh_binding(
1177
- binding: SpecBinding, spec_root: Path, artifact_root: Path
1178
- ) -> tuple[SpecBinding, tuple[DriftChange, ...]]:
1179
- path = spec_root / binding.path
1180
- if not path.is_file():
1181
- stale = replace(binding, status="missing", rules=tuple(_stale_rule(rule) for rule in binding.rules))
1182
- change = DriftChange("spec_missing", binding.spec_id, "", binding.hashes.file_sha256, "missing", binding.stages)
1183
- return stale, (change,)
1184
- metadata = load_spec_metadata(str(path))
1185
- existing = {rule.ref: rule for rule in binding.rules}
1186
- rules: list[RuleBinding] = []
1187
- changes: list[DriftChange] = []
1188
- if metadata.hashes.metadata_sha256 != binding.hashes.metadata_sha256:
1189
- changes.append(
1190
- DriftChange("metadata_changed", binding.spec_id, "", binding.hashes.metadata_sha256, metadata.hashes.metadata_sha256, ())
1191
- )
1192
- for current in metadata.rules:
1193
- if current.enforcement == "informational":
1194
- continue
1195
- prior = existing.pop(current.ref, None)
1196
- if prior is None:
1197
- rule = _rule_from_metadata(binding.spec_id, metadata, current)
1198
- changes.append(DriftChange("rule_added", binding.spec_id, current.ref, "", current.text_sha256, metadata.stages))
1199
- else:
1200
- rule, change = _refresh_existing_rule(binding, prior, current, metadata)
1201
- if change is not None:
1202
- changes.append(change)
1203
- rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
1204
- rules.append(rule)
1205
- changes.extend(artifact_changes)
1206
- for removed in existing.values():
1207
- rules.append(_removed_rule(removed))
1208
- changes.append(DriftChange("rule_removed", binding.spec_id, removed.ref, removed.text_sha256, "missing", tuple(removed.stage_status)))
1209
- updated = replace(binding, hashes=metadata.hashes, enforcement=metadata.enforcement, stages=metadata.stages, rules=tuple(rules), status="active")
1210
- return updated, tuple(changes)
1211
-
1212
-
1213
- def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
1214
- started = time.monotonic()
1215
- specs = Path(root) / ".code-flow" / "specs"
1216
- artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
1217
- bindings: list[SpecBinding] = []
1218
- changes: list[DriftChange] = []
1219
- for binding in context.bindings:
1220
- phase_started = time.monotonic()
1221
- updated, binding_changes = _refresh_binding(binding, specs, artifacts)
1222
- phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
1223
- bindings.append(updated)
1224
- changes.extend(binding_changes)
1225
- refreshed = replace(context, updated_at=_now(), bindings=tuple(bindings))
1226
- phase_timing("context.refresh.total", started)
1227
- if refreshed.bindings == context.bindings:
1228
- return DriftResult(context, tuple(changes))
1229
- return DriftResult(refreshed, tuple(changes))
1230
-
1231
-
1232
- def diff_spec_hashes(context: SpecContext, root: str) -> DriftResult:
1233
- return refresh_context(context, root)
1234
-
1235
-
1236
- def apply_decision(
1237
- context: SpecContext,
1238
- spec_id: str,
1239
- rule_ref: str,
1240
- stage: str,
1241
- decision: Decision,
1242
- batch: bool = False,
1243
- ) -> SpecContext:
1244
- if batch:
1245
- raise ContextError("batch_confirmation_forbidden", "batch", "N/A/豁免必须逐项确认")
1246
- _validate_decision(decision)
1247
- target_status = {"not_applicable": "not_applicable", "waived": "waived", "manual_verification": "verified"}[decision.kind]
1248
- found = False
1249
- bindings: list[SpecBinding] = []
1250
- for binding in context.bindings:
1251
- changed_rules: list[RuleBinding] = []
1252
- for rule in binding.rules:
1253
- if binding.spec_id == spec_id and rule.ref == rule_ref:
1254
- if stage not in rule.stage_status:
1255
- raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
1256
- statuses = dict(rule.stage_status)
1257
- statuses[stage] = replace(statuses[stage], status=target_status, decision=decision)
1258
- rule = replace(rule, stage_status=statuses)
1259
- found = True
1260
- changed_rules.append(rule)
1261
- bindings.append(replace(binding, rules=tuple(changed_rules)))
1262
- if not found:
1263
- raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
1264
- return replace(context, updated_at=_now(), bindings=tuple(bindings))
1265
-
1266
-
1267
- def _upsert_artifact_ref(
1268
- references: Sequence[ArtifactRef], replacement: ArtifactRef
1269
- ) -> tuple[ArtifactRef, ...]:
1270
- identity = (replacement.artifact, replacement.section_id, replacement.item_id)
1271
- updated: list[ArtifactRef] = []
1272
- replaced = False
1273
- for reference in references:
1274
- current = (reference.artifact, reference.section_id, reference.item_id)
1275
- if current != identity:
1276
- updated.append(reference)
1277
- elif not replaced:
1278
- updated.append(replacement)
1279
- replaced = True
1280
- if not replaced:
1281
- updated.append(replacement)
1282
- return tuple(updated)
1283
-
1284
-
1285
- def apply_artifact_ref(
1286
- context: SpecContext,
1287
- spec_id: str,
1288
- rule_ref: str,
1289
- stage: str,
1290
- reference: ArtifactRef,
1291
- ) -> SpecContext:
1292
- found = False
1293
- bindings: list[SpecBinding] = []
1294
- for binding in context.bindings:
1295
- rules: list[RuleBinding] = []
1296
- for rule in binding.rules:
1297
- if binding.spec_id == spec_id and rule.ref == rule_ref:
1298
- if stage not in rule.stage_status:
1299
- raise ContextError("unknown_stage", "stage", f"Rule 不适用于 {stage}")
1300
- statuses = dict(rule.stage_status)
1301
- current = statuses[stage]
1302
- refs = _upsert_artifact_ref(current.refs, reference)
1303
- if current.decision is not None:
1304
- statuses[stage] = replace(current, refs=refs)
1305
- else:
1306
- statuses[stage] = replace(current, status="applied", refs=refs)
1307
- rule = replace(rule, stage_status=statuses)
1308
- found = True
1309
- rules.append(rule)
1310
- bindings.append(replace(binding, rules=tuple(rules)))
1311
- if not found:
1312
- raise ContextError("unknown_rule", "rule_ref", f"未找到 {spec_id}#{rule_ref}")
1313
- return replace(context, updated_at=_now(), bindings=tuple(bindings))
1314
-
1315
-
1316
- def _json_payload(stream: IO[str]) -> Mapping[str, object]:
1317
- try:
1318
- raw = stream.read()
1319
- except (OSError, UnicodeError) as exc:
1320
- raise ContextError("invalid_json", "stdin", str(exc)) from exc
1321
- if not raw.strip():
1322
- # 空 stdin = 无 payload(例如 active doctor --resync 全参数走 CLI 开关)。
1323
- return {}
1324
- try:
1325
- loaded = json.loads(raw)
1326
- except json.JSONDecodeError as exc:
1327
- raise ContextError("invalid_json", "stdin", str(exc)) from exc
1328
- return _mapping(loaded, "stdin", "")
1329
-
1330
-
1331
- def _candidate_data(candidate: SpecCandidate) -> dict[str, object]:
1332
- return {
1333
- "spec_id": candidate.spec_id,
1334
- "path": candidate.path,
1335
- "scope": candidate.scope,
1336
- "priority": candidate.priority,
1337
- "matched_paths": list(candidate.matched_paths),
1338
- "description": candidate.metadata.description,
1339
- "enforcement": candidate.metadata.enforcement,
1340
- "stages": list(candidate.metadata.stages),
1341
- "hashes": {
1342
- "file_sha256": candidate.metadata.hashes.file_sha256,
1343
- "metadata_sha256": candidate.metadata.hashes.metadata_sha256,
1344
- "rules_sha256": candidate.metadata.hashes.rules_sha256,
1345
- },
1346
- "rules": [
1347
- {"ref": rule.ref, "text": rule.text, "enforcement": rule.enforcement}
1348
- for rule in candidate.metadata.rules
1349
- ],
1350
- }
1351
-
1352
-
1353
- def _catalog_command(args: argparse.Namespace) -> dict[str, object]:
1354
- candidates = resolve_candidates(args.root, args.stage, tuple(args.paths))
1355
- return {
1356
- "ok": True,
1357
- "stage": args.stage,
1358
- "total": len(candidates),
1359
- "candidates": [_candidate_data(item) for item in candidates],
1360
- }
1361
-
1362
-
1363
- def _application_ref(task_dir: str, value: Mapping[str, object]) -> ArtifactRef:
1364
- artifact = _string(value.get("artifact"), "applications[].artifact", "")
1365
- base = Path(task_dir).resolve()
1366
- target = (base / artifact).resolve()
1367
- if base != target and base not in target.parents:
1368
- raise ContextError("artifact_outside_task", "applications[].artifact", artifact, task_dir)
1369
- try:
1370
- artifact_hash = hashlib.sha256(target.read_bytes()).hexdigest()
1371
- except OSError as exc:
1372
- raise ContextError("artifact_read_error", "applications[].artifact", str(exc), str(target)) from exc
1373
- return ArtifactRef(
1374
- artifact,
1375
- _string(value.get("section_id"), "applications[].section_id", ""),
1376
- _string(value.get("item_id"), "applications[].item_id", ""),
1377
- artifact_hash,
1378
- )
1379
-
1380
-
1381
- def _apply_payload(context: SpecContext, task_dir: str, payload: Mapping[str, object]) -> SpecContext:
1382
- updated = context
1383
- for raw in _sequence(payload.get("applications", []), "applications", ""):
1384
- item = _mapping(raw, "applications[]", "")
1385
- updated = apply_artifact_ref(
1386
- updated,
1387
- _string(item.get("spec_id"), "applications[].spec_id", ""),
1388
- _string(item.get("rule_ref"), "applications[].rule_ref", ""),
1389
- _string(item.get("stage"), "applications[].stage", ""),
1390
- _application_ref(task_dir, item),
1391
- )
1392
- return updated
1393
-
1394
-
1395
- def _decision_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1396
- context_path = Path(args.task_dir) / "spec-context.yml"
1397
- decision = _decision_from_data(payload.get("decision"), str(context_path))
1398
- if decision is None:
1399
- raise ContextError("invalid_decision", "decision", "必须提供 decision")
1400
- context = apply_decision(
1401
- load_context(str(context_path)),
1402
- _string(payload.get("spec_id"), "spec_id", ""),
1403
- _string(payload.get("rule_ref"), "rule_ref", ""),
1404
- _string(payload.get("stage"), "stage", ""),
1405
- decision,
1406
- payload.get("batch") is True,
1407
- )
1408
- save_context(str(context_path), context)
1409
- _resync_after_save(args, context_path)
1410
- return {"ok": True, "status": context.bindings[0].rules[0].stage_status[_string(payload.get("stage"), "stage", "")].status}
1411
-
1412
-
1413
- def _bind_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1414
- context_path = Path(args.task_dir) / "spec-context.yml"
1415
- paths = tuple(_string(item, "paths[]", "") for item in _sequence(payload.get("paths", []), "paths", ""))
1416
- candidates = {item.spec_id: item for item in resolve_candidates(args.root, args.stage, paths)}
1417
- selections: list[BindingInput] = []
1418
- for raw in _sequence(payload.get("selections"), "selections", ""):
1419
- item = _mapping(raw, "selections[]", "")
1420
- spec_id = _string(item.get("spec_id"), "selections[].spec_id", "")
1421
- if spec_id not in candidates:
1422
- raise ContextError("unknown_candidate", "selections[].spec_id", spec_id)
1423
- selections.append(
1424
- BindingInput(
1425
- candidates[spec_id],
1426
- _string(item.get("selected_by"), "selections[].selected_by", ""),
1427
- _string(item.get("reason"), "selections[].reason", ""),
1428
- )
1429
- )
1430
- if context_path.exists():
1431
- context = load_context(str(context_path))
1432
- else:
1433
- task = _string(payload.get("task"), "task", "")
1434
- context = new_context(task, (("cli", "bind"),))
1435
- context = bind_specs(context, selections)
1436
- context = _apply_payload(context, args.task_dir, payload)
1437
- save_context(str(context_path), context)
1438
- _resync_after_save(args, context_path)
1439
- applied = sum(
1440
- status.status == "applied"
1441
- for binding in context.bindings
1442
- for rule in binding.rules
1443
- for status in rule.stage_status.values()
1444
- )
1445
- return {"ok": True, "bindings": len(context.bindings), "applied": applied}
1446
-
1447
-
1448
- def _active_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1449
- from cf_workflow_service import WorkflowError, locate_task_file
1450
- from cf_workflow_transaction import recover_transition
1451
-
1452
- recover_transition(args.root)
1453
- if args.active_action in ("pause", "resume", "block", "complete"):
1454
- marker, _ = _active_paths(args.root)
1455
- if marker.exists():
1456
- current = load_active_task(args.root)
1457
- if (current.task_id != args.task or
1458
- (Path(args.root) / current.task_dir).resolve() != (Path(args.root) / args.task_dir).resolve()):
1459
- raise ContextError("active_mismatch", "active", "requested TASK does not match marker", str(marker))
1460
-
1461
- def _directory() -> str:
1462
- return args.task_dir if Path(args.task_dir).is_absolute() else str(Path(args.root) / args.task_dir)
1463
-
1464
- def _reraise(exc: WorkflowError) -> ContextError:
1465
- return ContextError(exc.code, "workflow", str(exc), args.task_dir)
1466
-
1467
- task_file = locate_task_file(_directory(), args.task)
1468
- if args.active_action == "start":
1469
- owned = tuple(
1470
- _string(item, "owned_paths[]", "")
1471
- for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1472
- )
1473
- if task_file is None:
1474
- active = start_active_task(
1475
- args.root, args.task_dir, args.task, args.context_sha256, owned
1476
- )
1477
- return {"ok": True, "active": _active_data(active)}
1478
- from cf_workflow_service import start_task
1479
-
1480
- try:
1481
- return start_task(args.root, args.task_dir, str(task_file), args.task, owned)
1482
- except WorkflowError as exc:
1483
- raise _reraise(exc) from exc
1484
- if args.active_action == "pause":
1485
- active = pause_active_task(args.root)
1486
- elif args.active_action == "resume":
1487
- if task_file is None:
1488
- active = resume_active_task(args.root)
1489
- else:
1490
- from cf_workflow_service import resume_task
1491
-
1492
- try:
1493
- return resume_task(args.root, _directory(), str(task_file), args.task)
1494
- except WorkflowError as exc:
1495
- raise _reraise(exc) from exc
1496
- elif args.active_action == "block":
1497
- if task_file is None:
1498
- active = block_active_task(args.root)
1499
- else:
1500
- from cf_workflow_service import block_task
1501
-
1502
- reason = payload.get("reason", "")
1503
- try:
1504
- return block_task(args.root, _directory(), str(task_file), args.task, reason if isinstance(reason, str) else "")
1505
- except WorkflowError as exc:
1506
- raise _reraise(exc) from exc
1507
- elif args.active_action == "complete":
1508
- gate = payload.get("gate_passed") is True
1509
- if task_file is None:
1510
- active = complete_active_task(args.root, gate)
1511
- else:
1512
- from cf_workflow_service import complete_task
1513
-
1514
- try:
1515
- return complete_task(args.root, _directory(), str(task_file), args.task, gate)
1516
- except WorkflowError as exc:
1517
- raise _reraise(exc) from exc
1518
- else:
1519
- abandon = payload.get("abandon") is True or args.abandon
1520
- resync = payload.get("resync") is True or args.resync
1521
- if abandon and resync:
1522
- raise ContextError("conflicting_recovery", "doctor", "abandon 与 resync 不能同时使用", args.task_dir)
1523
- result = doctor_active_task(args.root, args.context_sha256, abandon, resync)
1524
- return {"ok": True, "action": result.action, "active": _active_data(result.active)}
1525
- return {"ok": True, "active": _active_data(active)}
1526
-
1527
-
1528
- def _start_command(args: argparse.Namespace, payload: Mapping[str, object]) -> dict[str, object]:
1529
- """Run refresh, activation and TASK projection in one guarded process.
1530
-
1531
- Delegates to the unified workflow service: hard preconditions (status,
1532
- NOTES, depends, marker) are enforced before any state changes, and the
1533
- Markdown view flips to in-progress in the same call.
1534
- """
1535
- from cf_workflow_service import WorkflowError, start_task
1536
-
1537
- owned = tuple(
1538
- _string(item, "owned_paths[]", "")
1539
- for item in _sequence(payload.get("owned_paths", []), "owned_paths", "")
1540
- )
1541
- try:
1542
- return start_task(
1543
- args.root, args.task_dir, args.task_file, args.task, owned, args.session_output
1544
- )
1545
- except WorkflowError as exc:
1546
- raise ContextError(exc.code, "workflow", str(exc), args.task_dir) from exc
1547
-
1548
-
1549
- def _status_command(args: argparse.Namespace) -> dict[str, object]:
1550
- """Human-readable Spec Context status: task, marker health, gate, rules."""
1551
- from cf_spec_gate import result_to_data, validate_stage # local import avoids module cycle
1552
-
1553
- context_path = Path(args.task_dir) / "spec-context.yml"
1554
- context = load_context(str(context_path))
1555
- gate = validate_stage(context, "code")
1556
- root = getattr(args, "root", "") or _find_root(args.task_dir) or ""
1557
- marker: dict[str, object] = {"exists": False}
1558
- if root:
1559
- marker_path = Path(root) / ".code-flow" / ".active-task.json"
1560
- if marker_path.exists():
1561
- active = load_active_task(root)
1562
- marker = {
1563
- "exists": True,
1564
- "task_id": active.task_id,
1565
- "status": active.status,
1566
- "hash_match": active.context_sha256 == context_sha256(context),
1567
- "baseline_head": active.baseline.head,
1568
- }
1569
- bindings = []
1570
- for binding in context.bindings:
1571
- rules = []
1572
- for rule in binding.rules:
1573
- status = rule.stage_status.get("code")
1574
- rules.append(
1575
- {
1576
- "ref": f"{binding.spec_id}#{rule.ref}",
1577
- "enforcement": rule.enforcement,
1578
- "status": status.status if status else "missing",
1579
- "verifier": rule.verifier_ref,
1580
- }
1581
- )
1582
- bindings.append({"spec_id": binding.spec_id, "path": binding.path, "status": binding.status, "rules": rules})
1583
- return {
1584
- "task": context.task,
1585
- "context_sha256": context_sha256(context),
1586
- "gate": result_to_data(gate),
1587
- "marker": marker,
1588
- "bindings": bindings,
1589
- }
1590
-
1591
-
1592
- def _status_text(data: dict[str, object]) -> str:
1593
- lines = [f"# {data['task']} — Spec Context 状态"]
1594
- marker = data["marker"]
1595
- if marker["exists"]:
1596
- match = "✓ 一致" if marker["hash_match"] else "✗ 漂移"
1597
- lines.append(f"- TASK {marker['task_id']}({marker['status']}),marker hash {match}")
1598
- if not marker["hash_match"]:
1599
- lines.append(" 下一步: 运行 cf-spec refresh 自动重同步 marker hash;仍不一致时用 active doctor --resync(hash 取本命令 --json 的 context_sha256)")
1600
- else:
1601
- lines.append("- 无 active TASK(path/catalog 路由模式)")
1602
- gate = data["gate"]
1603
- if gate["decision"] == "pass":
1604
- lines.append("- code Gate: ✓ pass")
1605
- else:
1606
- lines.append(f"- code Gate: ✗ block({len(gate['errors'])} 项)")
1607
- for issue in gate["errors"]:
1608
- lines.append(f" ✗ {issue['message']}")
1609
- for binding in data["bindings"]:
1610
- lines.append(f"- {binding['spec_id']}({binding['path']})— {binding['status']}")
1611
- for rule in binding["rules"]:
1612
- lines.append(f" {rule['status']:<12} {rule['ref']}({rule['verifier']})")
1613
- return "\n".join(lines)
1614
-
1615
-
1616
- def _parser() -> argparse.ArgumentParser:
1617
- parser = argparse.ArgumentParser(prog="cf_spec_context.py")
1618
- commands = parser.add_subparsers(dest="command", required=True)
1619
- catalog = commands.add_parser("catalog")
1620
- catalog.add_argument("--root", required=True)
1621
- catalog.add_argument("--stage", required=True)
1622
- catalog.add_argument("--paths", nargs="*", default=())
1623
- catalog.add_argument("--json", action="store_true")
1624
- decision = commands.add_parser("decision")
1625
- decision.add_argument("--task-dir", required=True)
1626
- decision.add_argument("--json", action="store_true")
1627
- bind = commands.add_parser("bind")
1628
- bind.add_argument("--task-dir", required=True)
1629
- bind.add_argument("--root", required=True)
1630
- bind.add_argument("--stage", required=True)
1631
- bind.add_argument("--json", action="store_true")
1632
- validate = commands.add_parser("validate")
1633
- validate.add_argument("--task-dir", required=True)
1634
- validate.add_argument("--json", action="store_true")
1635
- status = commands.add_parser("status")
1636
- status.add_argument("--task-dir", required=True)
1637
- status.add_argument("--root", default="")
1638
- status.add_argument("--json", action="store_true")
1639
- for name in ("refresh", "refresh-missing"):
1640
- refresh = commands.add_parser(name)
1641
- refresh.add_argument("--task-dir", required=True)
1642
- refresh.add_argument("--root", required=True)
1643
- refresh.add_argument("--json", action="store_true")
1644
- active = commands.add_parser("active")
1645
- active_actions = active.add_subparsers(dest="active_action", required=True)
1646
- for action in ("start", "pause", "resume", "block", "complete", "doctor"):
1647
- command = active_actions.add_parser(action)
1648
- command.add_argument("--root", required=True)
1649
- command.add_argument("--task-dir", required=True)
1650
- command.add_argument("--task", required=True)
1651
- command.add_argument("--context-sha256", required=True)
1652
- command.add_argument("--json", action="store_true")
1653
- if action == "doctor":
1654
- command.add_argument("--abandon", action="store_true", help="确认放弃无法证明的 marker(需用户确认)")
1655
- command.add_argument("--resync", action="store_true", help="用 --context-sha256 重新绑定 marker 并恢复 active")
1656
- start = commands.add_parser("start")
1657
- start.add_argument("--task-dir", required=True)
1658
- start.add_argument("--root", required=True)
1659
- start.add_argument("--task", required=True)
1660
- start.add_argument("--task-file", required=True)
1661
- start.add_argument("--session-output", default="")
1662
- start.add_argument("--json", action="store_true")
1663
- return parser
1664
-
1665
-
1666
- def _execute(args: argparse.Namespace, stdin: IO[str]) -> dict[str, object]:
1667
- if args.command == "catalog":
1668
- return _catalog_command(args)
1669
- if args.command == "active":
1670
- return _active_command(args, _json_payload(stdin))
1671
- if args.command == "start":
1672
- return _start_command(args, _json_payload(stdin))
1673
- context_path = Path(args.task_dir) / "spec-context.yml"
1674
- if args.command == "decision":
1675
- return _decision_command(args, _json_payload(stdin))
1676
- if args.command == "bind":
1677
- return _bind_command(args, _json_payload(stdin))
1678
- if args.command == "validate":
1679
- context = load_context(str(context_path))
1680
- return {"ok": True, "task": context.task, "bindings": len(context.bindings)}
1681
- if args.command == "refresh-missing":
1682
- context = refresh_missing_specs(load_context(str(context_path)), args.root)
1683
- changes: tuple[DriftChange, ...] = ()
1684
- else:
1685
- result = refresh_context(load_context(str(context_path)), args.root, artifact_root=args.task_dir)
1686
- context, changes = result.context, result.changes
1687
- save_context(str(context_path), context)
1688
- _resync_after_save(args, context_path)
1689
- return {
1690
- "ok": True,
1691
- "missing": sum(item.status == "missing" for item in context.bindings),
1692
- "changes": [change.__dict__ for change in changes],
1693
- }
1694
-
1695
-
1696
- def main(argv: Optional[Sequence[str]] = None, stdin: IO[str] = sys.stdin, stdout: IO[str] = sys.stdout) -> int:
1697
- try:
1698
- args = _parser().parse_args(argv)
1699
- if args.command == "status":
1700
- data = _status_command(args)
1701
- if args.json:
1702
- stdout.write(json.dumps(data, ensure_ascii=False))
1703
- else:
1704
- stdout.write(_status_text(data) + "\n")
1705
- return 0
1706
- result = _execute(args, stdin)
1707
- stdout.write(json.dumps(result, ensure_ascii=False))
1708
- return 0
1709
- except ContextError as exc:
1710
- stdout.write(json.dumps({"ok": False, "error": exc.to_dict()}, ensure_ascii=False))
1711
- return 3
1712
- except Exception as exc:
1713
- sys.stderr.write(f"cf_spec_context unexpected error: {exc}\n")
1714
- stdout.write(json.dumps({"ok": False, "error": {"code": "internal_error"}}, ensure_ascii=False))
1715
- return 2
1716
4
 
5
+ from codeflow.cli import cf_spec_context as _impl
1717
6
 
1718
7
  if __name__ == "__main__":
1719
- raise SystemExit(main())
8
+ raise SystemExit(_impl.main())
9
+ else:
10
+ sys.modules[__name__] = _impl