@michaelxwb/code-flow 0.6.5 → 1.0.0

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