@michaelxwb/code-flow 0.6.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -40
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,412 @@
1
+ """Active task marker, lock and Git ownership scope.
2
+
3
+ The marker is the persisted fact behind a running TASK; the Markdown view is
4
+ synced elsewhere. This module owns marker serialization, the cross-process
5
+ lock, and the Git baseline that defines owned paths.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, replace
11
+ from fnmatch import fnmatchcase
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Mapping, Optional, Sequence
16
+
17
+ from codeflow.core import cf_git
18
+ from codeflow.spec.cf_spec_context_model import (
19
+ ContextError,
20
+ atomic_text,
21
+ mapping_field,
22
+ now_iso,
23
+ sequence_field,
24
+ string_field,
25
+ )
26
+
27
+
28
+ ACTIVE_STATUSES = frozenset(("activating", "active", "paused", "blocked", "completed"))
29
+ DEFAULT_ACTIVE_EXCLUDES = (
30
+ ".code-flow/tasks/*",
31
+ ".code-flow/specs/_session/*",
32
+ ".code-flow/migrations/*",
33
+ ".code-flow/.*state*",
34
+ ".code-flow/.*cache*",
35
+ ".code-flow/.session-log.jsonl",
36
+ ".code-flow/.active-task.json",
37
+ ".code-flow/.active-task.lock",
38
+ ".code-flow/.stop.lock",
39
+ ".code-flow/worktrees/*",
40
+ ".code-flow/sessions/*",
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class PathSnapshot:
46
+ status: str
47
+ content_sha256: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ActiveBaseline:
52
+ head: Optional[str]
53
+ captured_at: str
54
+ preexisting_changes: Mapping[str, PathSnapshot]
55
+ # head is frozen at start and never rewritten: task scope is the union of
56
+ # everything committed since head plus all working-tree changes.
57
+ # last_seen_head is observation-only (diagnostics), never scope input.
58
+ last_seen_head: Optional[str] = None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ActiveTask:
63
+ version: int
64
+ task_dir: str
65
+ task_id: str
66
+ status: str
67
+ context_sha256: str
68
+ baseline: ActiveBaseline
69
+ owned_paths: tuple[str, ...]
70
+ excluded_paths: tuple[str, ...]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class DoctorResult:
75
+ action: str
76
+ active: ActiveTask
77
+
78
+
79
+ def active_paths(root: str) -> tuple[Path, Path]:
80
+ flow = Path(root) / ".code-flow"
81
+ return flow / ".active-task.json", flow / ".active-task.lock"
82
+
83
+
84
+ def _context_git_error(exc: cf_git.GitError, root: str) -> ContextError:
85
+ codes = ("git_unavailable", "invalid_git_status")
86
+ code = exc.code if exc.code in codes else "git_error"
87
+ return ContextError(code, "git", exc.message, root)
88
+
89
+
90
+ def _git_head(root: str) -> Optional[str]:
91
+ try:
92
+ return cf_git.run_git(root, ("rev-parse", "HEAD")).strip()
93
+ except cf_git.GitError as exc:
94
+ if "unknown revision" in exc.message or "ambiguous argument" in exc.message:
95
+ return None
96
+ raise _context_git_error(exc, root) from exc
97
+
98
+
99
+ def git_changes(root: str) -> Mapping[str, str]:
100
+ try:
101
+ return cf_git.changed_entries(root)
102
+ except cf_git.GitError as exc:
103
+ raise _context_git_error(exc, root) from exc
104
+
105
+
106
+ def is_excluded(path: str, patterns: Sequence[str]) -> bool:
107
+ return any(fnmatchcase(path, pattern) for pattern in patterns)
108
+
109
+
110
+ def active_data(active: ActiveTask) -> dict[str, object]:
111
+ changes = {
112
+ path: {"status": item.status, "content_sha256": item.content_sha256}
113
+ for path, item in sorted(active.baseline.preexisting_changes.items())
114
+ }
115
+ return {
116
+ "version": active.version,
117
+ "task_dir": active.task_dir,
118
+ "task_id": active.task_id,
119
+ "status": active.status,
120
+ "context_sha256": active.context_sha256,
121
+ "baseline": {
122
+ "head": active.baseline.head,
123
+ "last_seen_head": active.baseline.last_seen_head,
124
+ "captured_at": active.baseline.captured_at,
125
+ "preexisting_changes": changes,
126
+ },
127
+ "owned_paths": list(active.owned_paths),
128
+ "excluded_paths": list(active.excluded_paths),
129
+ }
130
+
131
+
132
+ def _snapshot_from_data(value: object, path: str) -> PathSnapshot:
133
+ data = mapping_field(value, "preexisting_changes[]", path)
134
+ content_hash = data.get("content_sha256")
135
+ return PathSnapshot(
136
+ string_field(data.get("status"), "status", path),
137
+ content_hash if isinstance(content_hash, str) else "",
138
+ )
139
+
140
+
141
+ def active_from_data(value: object, path: str) -> ActiveTask:
142
+ data = mapping_field(value, "active", path)
143
+ baseline_data = mapping_field(data.get("baseline"), "baseline", path)
144
+ raw_changes = mapping_field(
145
+ baseline_data.get("preexisting_changes"), "baseline.preexisting_changes", path
146
+ )
147
+ version = data.get("version")
148
+ head = baseline_data.get("head")
149
+ if version != 1 or (head is not None and not isinstance(head, str)):
150
+ raise ContextError("invalid_active_marker", "version/baseline.head", "schema 不受支持", path)
151
+ status = string_field(data.get("status"), "status", path)
152
+ if status not in ACTIVE_STATUSES:
153
+ raise ContextError("invalid_active_marker", "status", status, path)
154
+ baseline = ActiveBaseline(
155
+ head,
156
+ string_field(baseline_data.get("captured_at"), "captured_at", path),
157
+ {key: _snapshot_from_data(item, path) for key, item in raw_changes.items()},
158
+ baseline_data.get("last_seen_head") if isinstance(baseline_data.get("last_seen_head"), str) else None,
159
+ )
160
+ return ActiveTask(
161
+ 1,
162
+ string_field(data.get("task_dir"), "task_dir", path),
163
+ string_field(data.get("task_id"), "task_id", path),
164
+ status,
165
+ string_field(data.get("context_sha256"), "context_sha256", path),
166
+ baseline,
167
+ tuple(string_field(item, "owned_paths[]", path) for item in sequence_field(data.get("owned_paths"), "owned_paths", path)),
168
+ tuple(string_field(item, "excluded_paths[]", path) for item in sequence_field(data.get("excluded_paths"), "excluded_paths", path)),
169
+ )
170
+
171
+
172
+ def save_active_task(root: str, active: ActiveTask) -> None:
173
+ marker = active_paths(root)[0]
174
+ marker.parent.mkdir(parents=True, exist_ok=True)
175
+ atomic_text(marker, json.dumps(active_data(active), ensure_ascii=False, indent=2) + "\n")
176
+
177
+
178
+ def load_active_task(root: str) -> ActiveTask:
179
+ marker = active_paths(root)[0]
180
+ try:
181
+ value = json.loads(marker.read_text(encoding="utf-8"))
182
+ return active_from_data(value, str(marker))
183
+ except ContextError:
184
+ raise
185
+ except (OSError, json.JSONDecodeError) as exc:
186
+ raise ContextError("invalid_active_marker", "active", str(exc), str(marker)) from exc
187
+
188
+
189
+ def _lock_stale(lock: Path) -> bool:
190
+ """A lock is stale when its file is unreadable, carries no valid PID, or the
191
+ owning process is gone. A lock we cannot prove dead is never deleted (PID
192
+ reuse is accepted as a rare race)."""
193
+ try:
194
+ data = json.loads(lock.read_text(encoding="utf-8"))
195
+ except (OSError, json.JSONDecodeError):
196
+ return True
197
+ pid = data.get("pid")
198
+ if not isinstance(pid, int) or pid <= 0:
199
+ return True
200
+ try:
201
+ os.kill(pid, 0)
202
+ except ProcessLookupError:
203
+ return True
204
+ except (PermissionError, OSError):
205
+ return False
206
+ return False
207
+
208
+
209
+ def acquire_active_lock(root: str) -> Path:
210
+ marker, lock = active_paths(root)
211
+ lock.parent.mkdir(parents=True, exist_ok=True)
212
+ descriptor = -1
213
+ for _attempt in range(2):
214
+ try:
215
+ descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
216
+ break
217
+ except FileExistsError:
218
+ if not _lock_stale(lock):
219
+ raise ContextError("active_lock_exists", "active.lock", "运行 doctor 检查残留 lock", str(lock)) from None
220
+ try:
221
+ lock.unlink()
222
+ except OSError as exc:
223
+ raise ContextError("active_lock_exists", "active.lock", f"残留 lock 清理失败: {exc}", str(lock)) from exc
224
+ else:
225
+ raise ContextError("active_lock_exists", "active.lock", "残留 lock 无法清理", str(lock))
226
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
227
+ handle.write(json.dumps({"pid": os.getpid(), "marker": str(marker)}))
228
+ return lock
229
+
230
+
231
+ def release_active_lock(lock: Path) -> None:
232
+ try:
233
+ lock.unlink()
234
+ except FileNotFoundError:
235
+ return
236
+
237
+
238
+ def committed_since(root: str, base_head: Optional[str]) -> Mapping[str, str]:
239
+ """Paths committed between the frozen baseline head and current HEAD.
240
+
241
+ Returns {} when there is no baseline (pre-commit repo) or the range is
242
+ unresolvable (rewritten/pruned history) — working-tree changes still
243
+ flow through business_changes, so scope never goes fully blind.
244
+ """
245
+ if not base_head:
246
+ return {}
247
+ try:
248
+ output = cf_git.run_git(root, ("diff", "--name-status", "-z", base_head, "HEAD", "--"))
249
+ except cf_git.GitError:
250
+ return {}
251
+ names: dict[str, str] = {}
252
+ tokens = output.split("\0")
253
+ index = 0
254
+ while index < len(tokens) and tokens[index]:
255
+ status, index = tokens[index], index + 1
256
+ code = status[:1]
257
+ if code in ("R", "C"):
258
+ if index + 1 >= len(tokens):
259
+ break
260
+ new_path, old_path = tokens[index], tokens[index + 1]
261
+ index += 2
262
+ if new_path:
263
+ names[new_path] = "renamed"
264
+ if old_path:
265
+ names[old_path] = "deleted"
266
+ continue
267
+ if index >= len(tokens) or not tokens[index]:
268
+ break
269
+ names[tokens[index]] = {"M": "modified", "A": "added", "D": "deleted"}.get(code, "changed")
270
+ index += 1
271
+ return names
272
+
273
+
274
+ def business_changes(root: str, excludes: Sequence[str]) -> Mapping[str, str]:
275
+ return {
276
+ path: status
277
+ for path, status in git_changes(root).items()
278
+ if not is_excluded(path, excludes)
279
+ }
280
+
281
+
282
+ def new_active(
283
+ root: str,
284
+ task_dir: str,
285
+ task_id: str,
286
+ context_sha256: str,
287
+ owned_preexisting: Sequence[str],
288
+ ) -> ActiveTask:
289
+ changes = business_changes(root, DEFAULT_ACTIVE_EXCLUDES)
290
+ owned = tuple(sorted(set(owned_preexisting)))
291
+ unknown = tuple(path for path in owned if path not in changes)
292
+ unowned = tuple(path for path in changes if path not in owned)
293
+ if unknown:
294
+ raise ContextError("unknown_owned_path", "owned_paths", ", ".join(unknown), root)
295
+ if unowned:
296
+ raise ContextError("unowned_changes", "owned_paths", ", ".join(unowned), root)
297
+ # content_sha256 was a per-file hash but is never consumed by any gate (only
298
+ # baseline.head is compared); keep the schema field empty so start never hashes
299
+ # every uncommitted file.
300
+ snapshots = {
301
+ path: PathSnapshot(status, "") for path, status in changes.items()
302
+ }
303
+ head = _git_head(root)
304
+ baseline = ActiveBaseline(head, now_iso(), snapshots, head)
305
+ return ActiveTask(
306
+ 1, task_dir, task_id, "activating", context_sha256, baseline, owned, DEFAULT_ACTIVE_EXCLUDES
307
+ )
308
+
309
+
310
+ def start_active_task(
311
+ root: str,
312
+ task_dir: str,
313
+ task_id: str,
314
+ context_sha256: str,
315
+ owned_preexisting: Sequence[str] = (),
316
+ ) -> ActiveTask:
317
+ marker = active_paths(root)[0]
318
+ lock = acquire_active_lock(root)
319
+ try:
320
+ if marker.exists():
321
+ raise ContextError("active_exists", "active", "当前 worktree 已有 active TASK", str(marker))
322
+ active = new_active(root, task_dir, task_id, context_sha256, owned_preexisting)
323
+ save_active_task(root, active)
324
+ active = replace(active, status="active")
325
+ save_active_task(root, active)
326
+ return active
327
+ finally:
328
+ release_active_lock(lock)
329
+
330
+
331
+ def _transition_active(root: str, expected: Sequence[str], target: str) -> ActiveTask:
332
+ lock = acquire_active_lock(root)
333
+ try:
334
+ active = load_active_task(root)
335
+ if active.status not in expected:
336
+ raise ContextError("invalid_active_transition", "status", f"{active.status} -> {target}", root)
337
+ current_head = _git_head(root)
338
+ if active.baseline.head != current_head:
339
+ # Mid-task commits are a normal workflow. The baseline head stays
340
+ # frozen so committed work never drops out of scope; only the
341
+ # observation field advances for diagnostics.
342
+ active = replace(active, baseline=replace(active.baseline, last_seen_head=current_head))
343
+ updated = replace(active, status=target)
344
+ save_active_task(root, updated)
345
+ return updated
346
+ finally:
347
+ release_active_lock(lock)
348
+
349
+
350
+ def pause_active_task(root: str) -> ActiveTask:
351
+ return _transition_active(root, ("active",), "paused")
352
+
353
+
354
+ def block_active_task(root: str) -> ActiveTask:
355
+ return _transition_active(root, ("active",), "blocked")
356
+
357
+
358
+ def resume_active_task(root: str) -> ActiveTask:
359
+ return _transition_active(root, ("paused", "blocked"), "active")
360
+
361
+
362
+ def current_owned_paths(root: str, active: ActiveTask) -> tuple[str, ...]:
363
+ changes = business_changes(root, active.excluded_paths)
364
+ committed = {
365
+ path: status
366
+ for path, status in committed_since(root, active.baseline.head).items()
367
+ if not is_excluded(path, active.excluded_paths)
368
+ }
369
+ return tuple(sorted(set(active.owned_paths).union(changes).union(committed)))
370
+
371
+
372
+ def complete_active_task(root: str, gate_passed: bool) -> ActiveTask:
373
+ if not gate_passed:
374
+ raise ContextError("gate_failed", "gate", "Done Gate 未通过", root)
375
+ completed = _transition_active(root, ("active",), "completed")
376
+ marker = active_paths(root)[0]
377
+ try:
378
+ marker.unlink()
379
+ except OSError as exc:
380
+ raise ContextError("active_cleanup_failed", "active", str(exc), str(marker)) from exc
381
+ return completed
382
+
383
+
384
+ def doctor_active_task(
385
+ root: str, expected_context_sha256: str, abandon: bool = False, resync: bool = False
386
+ ) -> DoctorResult:
387
+ marker, lock = active_paths(root)
388
+ try:
389
+ active = load_active_task(root)
390
+ except ContextError as exc:
391
+ raise ContextError("recovery_required", "active", exc.message, str(marker)) from exc
392
+ if abandon:
393
+ marker.unlink()
394
+ release_active_lock(lock)
395
+ return DoctorResult("abandoned", replace(active, status="completed"))
396
+ if resync:
397
+ # Self-heal: the context file is authoritative and the marker hash is a
398
+ # cache. Accept the current context when the marker is the only
399
+ # inconsistency; bind/decision/refresh already re-sync automatically,
400
+ # so this covers legacy markers and manual context edits.
401
+ repaired = replace(active, status="active", context_sha256=expected_context_sha256)
402
+ save_active_task(root, repaired)
403
+ release_active_lock(lock)
404
+ return DoctorResult("resynced", repaired)
405
+ proven = active.context_sha256 == expected_context_sha256
406
+ proven = proven and active.baseline.head == _git_head(root)
407
+ if active.status != "activating" or not proven:
408
+ raise ContextError("recovery_required", "active", "状态或 hash 无法证明可自动恢复", str(marker))
409
+ repaired = replace(active, status="active")
410
+ save_active_task(root, repaired)
411
+ release_active_lock(lock)
412
+ return DoctorResult("resumed", repaired)