@michaelxwb/code-flow 0.6.5 → 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 -42
  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 +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  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 -42
  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 +52 -39
  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 +69 -35
  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 -42
  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 +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  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 -42
  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 +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  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 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  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 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  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} +5 -2
  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} +163 -35
  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 -304
  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,477 @@
1
+ """Pure Spec Context model: dataclasses, serialization and identity hashing.
2
+
3
+ This module owns the persisted schema and its stable error contract. CLI
4
+ commands, doctor and decision orchestration stay in `cf_spec_context`; lower
5
+ layers (cache, evidence, plan quality) depend on this model instead of the CLI
6
+ entry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ import hashlib
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Mapping, Optional, Sequence
16
+
17
+ from codeflow.core import cf_fs
18
+ from codeflow.spec.cf_spec_metadata import SpecHashes
19
+
20
+ if TYPE_CHECKING:
21
+ from codeflow.spec.cf_spec_resolver import SpecCandidate
22
+
23
+
24
+ CONTEXT_VERSION = 1
25
+ STAGE_STATUSES = frozenset(
26
+ ("pending", "applied", "not_applicable", "waived", "verified", "stale", "conflict", "unverified")
27
+ )
28
+ DECISION_KINDS = frozenset(("not_applicable", "waived", "manual_verification"))
29
+ _AGENT_IDENTITIES = frozenset(("agent", "assistant", "codex", "claude", "opencode", "costrict"))
30
+
31
+
32
+ class ContextError(ValueError):
33
+ """A stable context error suitable for JSON CLI output."""
34
+
35
+ def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
36
+ self.code = code
37
+ self.field = field
38
+ self.message = message
39
+ self.path = path
40
+ location = f"{path}: " if path else ""
41
+ super().__init__(f"{location}{code} {field}: {message}")
42
+
43
+ def to_dict(self) -> dict[str, str]:
44
+ return {"code": self.code, "field": self.field, "message": self.message, "path": self.path}
45
+
46
+
47
+ def error_payload(exc: Exception) -> Optional[dict]:
48
+ """Duck-typed ContextError recognition across duplicate module instances.
49
+
50
+ Direct script execution (`python3 cf_spec_context.py`) can make lazy
51
+ `import cf_spec_context` calls inside sibling modules load a second copy,
52
+ so `isinstance(exc, ContextError)` fails even though the error contract is
53
+ identical. Recognize it structurally instead of masking it as
54
+ `internal_error`.
55
+ """
56
+ to_dict = getattr(exc, "to_dict", None)
57
+ if not callable(to_dict) or not isinstance(getattr(exc, "code", None), str):
58
+ return None
59
+ payload = to_dict()
60
+ return payload if isinstance(payload, dict) else None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ContextSource:
65
+ type: str
66
+ ref: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Decision:
71
+ kind: str
72
+ reason: str
73
+ confirmed_by: str
74
+ confirmed_at: str
75
+ source: str
76
+ expires_at: Optional[str]
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ArtifactRef:
81
+ artifact: str
82
+ section_id: str
83
+ item_id: str
84
+ artifact_sha256: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RuleStageStatus:
89
+ status: str
90
+ refs: tuple[ArtifactRef, ...]
91
+ decision: Optional[Decision]
92
+ evidence: tuple[Mapping[str, object], ...]
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class RuleBinding:
97
+ ref: str
98
+ summary: str
99
+ text_sha256: str
100
+ enforcement: str
101
+ verifier_ref: str
102
+ stage_status: Mapping[str, RuleStageStatus]
103
+ verifier_stage: str = "code"
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class SpecBinding:
108
+ spec_id: str
109
+ path: str
110
+ hashes: SpecHashes
111
+ selected_by: str
112
+ reason: str
113
+ enforcement: str
114
+ stages: tuple[str, ...]
115
+ rules: tuple[RuleBinding, ...]
116
+ status: str = "active"
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class SpecContext:
121
+ version: int
122
+ task: str
123
+ enforcement: str
124
+ updated_at: str
125
+ sources: tuple[ContextSource, ...]
126
+ bindings: tuple[SpecBinding, ...]
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class BindingInput:
131
+ candidate: SpecCandidate
132
+ selected_by: str
133
+ reason: str
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class DriftChange:
138
+ kind: str
139
+ spec_id: str
140
+ rule_ref: str
141
+ old_hash: str
142
+ new_hash: str
143
+ stages: tuple[str, ...]
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class DriftResult:
148
+ context: SpecContext
149
+ changes: tuple[DriftChange, ...]
150
+
151
+
152
+ def now_iso() -> str:
153
+ return datetime.now(timezone.utc).isoformat()
154
+
155
+
156
+ def atomic_text(target: Path, text: str) -> None:
157
+ """Atomic write with the Context's stable error code on failure."""
158
+ try:
159
+ cf_fs.atomic_write_text(target, text)
160
+ except OSError as exc:
161
+ raise ContextError("atomic_write_error", "file", str(exc), str(target)) from exc
162
+
163
+
164
+ def mapping_field(value: object, field: str, path: str) -> Mapping[str, object]:
165
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
166
+ raise ContextError("invalid_context", field, "必须是字符串 key mapping", path)
167
+ return value
168
+
169
+
170
+ def sequence_field(value: object, field: str, path: str) -> Sequence[object]:
171
+ if not isinstance(value, list):
172
+ raise ContextError("invalid_context", field, "必须是列表", path)
173
+ return value
174
+
175
+
176
+ def string_field(value: object, field: str, path: str) -> str:
177
+ if not isinstance(value, str) or not value.strip():
178
+ raise ContextError("invalid_context", field, "必须是非空字符串", path)
179
+ return value.strip()
180
+
181
+
182
+ def _decision_string(value: object, field: str, path: str) -> str:
183
+ if not isinstance(value, str) or not value.strip():
184
+ raise ContextError("invalid_decision", field, "必须是非空字符串", path)
185
+ return value.strip()
186
+
187
+
188
+ def _timestamp(value: str, field: str) -> None:
189
+ try:
190
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
191
+ except ValueError as exc:
192
+ raise ContextError("invalid_decision", field, "必须是 RFC3339 时间") from exc
193
+ if parsed.tzinfo is None:
194
+ raise ContextError("invalid_decision", field, "必须包含时区")
195
+
196
+
197
+ def validate_decision(decision: Decision) -> None:
198
+ if decision.kind not in DECISION_KINDS:
199
+ raise ContextError("invalid_decision", "decision.kind", f"不支持 {decision.kind!r}")
200
+ for field in ("reason", "confirmed_by", "confirmed_at", "source"):
201
+ if not getattr(decision, field).strip():
202
+ raise ContextError("invalid_decision", f"decision.{field}", "必须是非空字符串")
203
+ identity = decision.confirmed_by.lower().split(":", 1)[0]
204
+ if identity in _AGENT_IDENTITIES:
205
+ raise ContextError("agent_confirmation_forbidden", "decision.confirmed_by", "Agent 不能自行确认")
206
+ _timestamp(decision.confirmed_at, "decision.confirmed_at")
207
+ if decision.kind == "waived" and not decision.expires_at:
208
+ raise ContextError("invalid_decision", "decision.expires_at", "waived 必须有失效时间")
209
+ if decision.expires_at:
210
+ _timestamp(decision.expires_at, "decision.expires_at")
211
+
212
+
213
+ def decision_from_data(value: object, path: str) -> Optional[Decision]:
214
+ if value is None:
215
+ return None
216
+ data = mapping_field(value, "decision", path)
217
+ expires = data.get("expires_at")
218
+ if expires is not None and not isinstance(expires, str):
219
+ raise ContextError("invalid_decision", "decision.expires_at", "必须是字符串或 null", path)
220
+ decision = Decision(
221
+ _decision_string(data.get("kind"), "decision.kind", path),
222
+ _decision_string(data.get("reason"), "decision.reason", path),
223
+ _decision_string(data.get("confirmed_by"), "decision.confirmed_by", path),
224
+ _decision_string(data.get("confirmed_at"), "decision.confirmed_at", path),
225
+ _decision_string(data.get("source"), "decision.source", path),
226
+ expires,
227
+ )
228
+ validate_decision(decision)
229
+ return decision
230
+
231
+
232
+ def _decision_data(decision: Optional[Decision]) -> Optional[dict[str, object]]:
233
+ if decision is None:
234
+ return None
235
+ return {
236
+ "kind": decision.kind,
237
+ "reason": decision.reason,
238
+ "confirmed_by": decision.confirmed_by,
239
+ "confirmed_at": decision.confirmed_at,
240
+ "source": decision.source,
241
+ "expires_at": decision.expires_at,
242
+ }
243
+
244
+
245
+ def _artifact_from_data(value: object, path: str) -> ArtifactRef:
246
+ data = mapping_field(value, "refs[]", path)
247
+ return ArtifactRef(
248
+ string_field(data.get("artifact"), "refs[].artifact", path),
249
+ string_field(data.get("section_id"), "refs[].section_id", path),
250
+ string_field(data.get("item_id"), "refs[].item_id", path),
251
+ string_field(data.get("artifact_sha256"), "refs[].artifact_sha256", path),
252
+ )
253
+
254
+
255
+ def _artifact_data(reference: ArtifactRef) -> dict[str, str]:
256
+ return {
257
+ "artifact": reference.artifact,
258
+ "section_id": reference.section_id,
259
+ "item_id": reference.item_id,
260
+ "artifact_sha256": reference.artifact_sha256,
261
+ }
262
+
263
+
264
+ def _stage_from_data(value: object, field: str, path: str) -> RuleStageStatus:
265
+ data = mapping_field(value, field, path)
266
+ status = string_field(data.get("status"), f"{field}.status", path)
267
+ if status not in STAGE_STATUSES:
268
+ raise ContextError("invalid_context", f"{field}.status", f"不支持 {status!r}", path)
269
+ refs = tuple(_artifact_from_data(item, path) for item in sequence_field(data.get("refs", []), f"{field}.refs", path))
270
+ evidence = tuple(mapping_field(item, f"{field}.evidence[]", path) for item in sequence_field(data.get("evidence", []), f"{field}.evidence", path))
271
+ decision = decision_from_data(data.get("decision"), path)
272
+ if status == "applied" and not refs:
273
+ raise ContextError("invalid_context", f"{field}.refs", "applied 必须有 artifact ref", path)
274
+ if status in ("not_applicable", "waived") and decision is None:
275
+ raise ContextError("invalid_context", f"{field}.decision", f"{status} 必须有确认记录", path)
276
+ return RuleStageStatus(status, refs, decision, evidence)
277
+
278
+
279
+ def _stage_data(status: RuleStageStatus) -> dict[str, object]:
280
+ return {
281
+ "status": status.status,
282
+ "refs": [_artifact_data(item) for item in status.refs],
283
+ "decision": _decision_data(status.decision),
284
+ "evidence": [dict(item) for item in status.evidence],
285
+ }
286
+
287
+
288
+ def _rule_from_data(value: object, path: str) -> RuleBinding:
289
+ data = mapping_field(value, "rules[]", path)
290
+ stages = mapping_field(data.get("stage_status"), "rules[].stage_status", path)
291
+ parsed = {stage: _stage_from_data(item, f"stage_status.{stage}", path) for stage, item in stages.items()}
292
+ return RuleBinding(
293
+ string_field(data.get("ref"), "rules[].ref", path),
294
+ string_field(data.get("summary"), "rules[].summary", path),
295
+ string_field(data.get("text_sha256"), "rules[].text_sha256", path),
296
+ string_field(data.get("enforcement"), "rules[].enforcement", path),
297
+ string_field(data.get("verifier_ref"), "rules[].verifier_ref", path),
298
+ parsed,
299
+ string_field(data.get("verifier_stage", "code"), "rules[].verifier_stage", path),
300
+ )
301
+
302
+
303
+ def _rule_data(rule: RuleBinding) -> dict[str, object]:
304
+ return {
305
+ "ref": rule.ref,
306
+ "summary": rule.summary,
307
+ "text_sha256": rule.text_sha256,
308
+ "enforcement": rule.enforcement,
309
+ "verifier_ref": rule.verifier_ref,
310
+ "verifier_stage": rule.verifier_stage,
311
+ "stage_status": {stage: _stage_data(status) for stage, status in rule.stage_status.items()},
312
+ }
313
+
314
+
315
+ def _hashes_from_data(value: object, path: str) -> SpecHashes:
316
+ data = mapping_field(value, "hashes", path)
317
+ return SpecHashes(
318
+ string_field(data.get("file_sha256"), "hashes.file_sha256", path),
319
+ string_field(data.get("metadata_sha256"), "hashes.metadata_sha256", path),
320
+ string_field(data.get("rules_sha256"), "hashes.rules_sha256", path),
321
+ )
322
+
323
+
324
+ def _binding_from_data(value: object, path: str) -> SpecBinding:
325
+ data = mapping_field(value, "bindings[]", path)
326
+ stages = tuple(string_field(item, "bindings[].stages[]", path) for item in sequence_field(data.get("stages"), "bindings[].stages", path))
327
+ rules = tuple(_rule_from_data(item, path) for item in sequence_field(data.get("rules"), "bindings[].rules", path))
328
+ return SpecBinding(
329
+ string_field(data.get("spec_id"), "bindings[].spec_id", path),
330
+ string_field(data.get("path"), "bindings[].path", path),
331
+ _hashes_from_data(data.get("hashes"), path),
332
+ string_field(data.get("selected_by"), "bindings[].selected_by", path),
333
+ string_field(data.get("reason"), "bindings[].reason", path),
334
+ string_field(data.get("enforcement"), "bindings[].enforcement", path),
335
+ stages,
336
+ rules,
337
+ string_field(data.get("status", "active"), "bindings[].status", path),
338
+ )
339
+
340
+
341
+ def _binding_data(binding: SpecBinding) -> dict[str, object]:
342
+ return {
343
+ "spec_id": binding.spec_id,
344
+ "path": binding.path,
345
+ "status": binding.status,
346
+ "hashes": {
347
+ "file_sha256": binding.hashes.file_sha256,
348
+ "metadata_sha256": binding.hashes.metadata_sha256,
349
+ "rules_sha256": binding.hashes.rules_sha256,
350
+ },
351
+ "selected_by": binding.selected_by,
352
+ "reason": binding.reason,
353
+ "enforcement": binding.enforcement,
354
+ "stages": list(binding.stages),
355
+ "rules": [_rule_data(rule) for rule in binding.rules],
356
+ }
357
+
358
+
359
+ def context_to_data(context: SpecContext) -> dict[str, object]:
360
+ return {
361
+ "version": context.version,
362
+ "task": context.task,
363
+ "enforcement": context.enforcement,
364
+ "updated_at": context.updated_at,
365
+ "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
366
+ "bindings": [_binding_data(binding) for binding in context.bindings],
367
+ }
368
+
369
+
370
+ def context_to_identity(context: SpecContext) -> dict[str, object]:
371
+ """Stable identity projection for marker drift detection.
372
+
373
+ Excludes volatile runtime state (`updated_at`, `stage_status` including
374
+ evidence timestamps) so the active marker only invalidates when the bound
375
+ rules themselves change (rule/spec text, bindings, sources).
376
+ """
377
+ return {
378
+ "version": context.version,
379
+ "task": context.task,
380
+ "enforcement": context.enforcement,
381
+ "sources": [{"type": item.type, "ref": item.ref} for item in context.sources],
382
+ "bindings": [
383
+ {
384
+ "spec_id": binding.spec_id,
385
+ "path": binding.path,
386
+ "status": binding.status,
387
+ "hashes": {
388
+ "file_sha256": binding.hashes.file_sha256,
389
+ "metadata_sha256": binding.hashes.metadata_sha256,
390
+ "rules_sha256": binding.hashes.rules_sha256,
391
+ },
392
+ "selected_by": binding.selected_by,
393
+ "reason": binding.reason,
394
+ "enforcement": binding.enforcement,
395
+ "stages": list(binding.stages),
396
+ "rules": [
397
+ {
398
+ "ref": rule.ref,
399
+ "summary": rule.summary,
400
+ "text_sha256": rule.text_sha256,
401
+ "enforcement": rule.enforcement,
402
+ "verifier_ref": rule.verifier_ref,
403
+ }
404
+ for rule in binding.rules
405
+ ],
406
+ }
407
+ for binding in context.bindings
408
+ ],
409
+ }
410
+
411
+
412
+ def context_sha256(context: SpecContext) -> str:
413
+ """Deterministic identity hash binding the active marker to the bound rules.
414
+
415
+ Computed from the stable identity projection (never volatile runtime state),
416
+ so evidence timestamps and stage statuses cannot drift the marker.
417
+ """
418
+ import yaml
419
+ data = yaml.safe_dump(context_to_identity(context), sort_keys=True, allow_unicode=True).encode()
420
+ return hashlib.sha256(data).hexdigest()
421
+
422
+
423
+ def new_context(task: str, sources: Sequence[tuple[str, str]]) -> SpecContext:
424
+ if not task.strip():
425
+ raise ContextError("invalid_context", "task", "必须是非空字符串")
426
+ parsed = tuple(ContextSource(string_field(kind, "sources[].type", ""), string_field(ref, "sources[].ref", "")) for kind, ref in sources)
427
+ return SpecContext(CONTEXT_VERSION, task.strip(), "required", now_iso(), parsed, ())
428
+
429
+
430
+ def load_context(path: str, *, source: Optional[bytes] = None) -> SpecContext:
431
+ import yaml
432
+ try:
433
+ raw = Path(path).read_bytes() if source is None else source
434
+ loaded = yaml.safe_load(raw.decode("utf-8")) or {}
435
+ except (OSError, UnicodeError, yaml.YAMLError) as exc:
436
+ raise ContextError("context_read_error", "file", str(exc), path) from exc
437
+ return context_from_data(loaded, path)
438
+
439
+
440
+ def context_from_data(loaded: object, path: str) -> SpecContext:
441
+ """Validate a parsed Context through the same schema for YAML and cache reads."""
442
+ data = mapping_field(loaded, "context", path)
443
+ version = data.get("version")
444
+ if version != CONTEXT_VERSION:
445
+ raise ContextError("unsupported_version", "version", f"仅支持 {CONTEXT_VERSION},收到 {version!r}", path)
446
+ source_values = sequence_field(data.get("sources", []), "sources", path)
447
+ sources = tuple(
448
+ ContextSource(
449
+ string_field(mapping_field(item, "sources[]", path).get("type"), "sources[].type", path),
450
+ string_field(mapping_field(item, "sources[]", path).get("ref"), "sources[].ref", path),
451
+ )
452
+ for item in source_values
453
+ )
454
+ bindings = tuple(_binding_from_data(item, path) for item in sequence_field(data.get("bindings", []), "bindings", path))
455
+ return SpecContext(
456
+ CONTEXT_VERSION,
457
+ string_field(data.get("task"), "task", path),
458
+ string_field(data.get("enforcement"), "enforcement", path),
459
+ string_field(data.get("updated_at"), "updated_at", path),
460
+ sources,
461
+ bindings,
462
+ )
463
+
464
+
465
+ def serialize_context(context: SpecContext) -> str:
466
+ import yaml
467
+ return yaml.safe_dump(context_to_data(context), sort_keys=False, allow_unicode=True)
468
+
469
+
470
+ def save_context(path: str, context: SpecContext) -> None:
471
+ target = Path(path)
472
+ target.parent.mkdir(parents=True, exist_ok=True)
473
+ text = serialize_context(context)
474
+ try:
475
+ atomic_text(target, text)
476
+ except ContextError as exc:
477
+ raise ContextError("context_write_error", "file", str(exc), path) from exc
@@ -0,0 +1,119 @@
1
+ """Read-only, bounded impact analysis for Spec Contexts and task documents."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Callable, Iterator
8
+
9
+ from codeflow.spec.cf_spec_context_model import ContextError, load_context
10
+ from codeflow.spec.cf_spec_resolver import resolve_candidates
11
+ from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
12
+
13
+
14
+ def _identity(root: str, value: str) -> tuple[str, str]:
15
+ query = value.replace('\\', '/').removeprefix('.code-flow/specs/')
16
+ found: dict[str, str] = {}
17
+ for stage in ('prd', 'design', 'plan', 'code', 'review'):
18
+ for item in resolve_candidates(root, stage, ()):
19
+ if query in (item.spec_id, item.path):
20
+ found[item.spec_id] = item.path
21
+ if len(found) != 1:
22
+ raise ValueError('spec_not_found' if not found else 'spec_identity_ambiguous')
23
+ return next(iter(found.items()))
24
+
25
+
26
+ def _files(root: Path, max_depth: int, onerror: Callable[[OSError], None]) -> Iterator[Path | None]:
27
+ if not root.exists():
28
+ return
29
+ for current, dirs, names in os.walk(root, followlinks=False, onerror=onerror):
30
+ directory = Path(current)
31
+ depth = len(directory.relative_to(root).parts)
32
+ blocked = [name for name in dirs if depth >= max_depth or (directory / name).is_symlink()]
33
+ dirs[:] = sorted(name for name in dirs if name not in blocked)
34
+ if blocked:
35
+ yield None
36
+ for name in sorted(names):
37
+ if name == 'spec-context.yml' or (name.endswith('.md') and not name.endswith(('.prd.md', '.design.md', '.scenarios.md'))):
38
+ yield directory / name
39
+
40
+
41
+ def _context_references(path: Path, root: Path, identities: dict[str, str]) -> list[dict[str, object]]:
42
+ context = load_context(str(path))
43
+ rows: list[dict[str, object]] = []
44
+ for binding in context.bindings:
45
+ if binding.spec_id not in identities:
46
+ continue
47
+ for rule in binding.rules:
48
+ if rule.enforcement != 'required':
49
+ continue
50
+ stage = rule.stage_status.get('code') or rule.stage_status.get('design')
51
+ rows.append({'context_path': path.relative_to(root).as_posix(), 'task_id': context.task,
52
+ 'spec_id': binding.spec_id, 'rule_ref': f'{binding.spec_id}#{rule.ref}',
53
+ 'rule_status': stage.status if stage else 'unknown',
54
+ 'source': 'context', 'archived': 'archived' in path.parts})
55
+ return rows
56
+
57
+
58
+ def _task_references(path: Path, root: Path, identities: dict[str, str]) -> list[dict[str, object]]:
59
+ text = path.read_text(encoding='utf-8')
60
+ candidates = {name: spec_path for name, spec_path in identities.items() if name in text or spec_path in text}
61
+ if not candidates:
62
+ return []
63
+ try:
64
+ document = parse_task_document(text, source=str(path))
65
+ except TaskDocumentError as exc:
66
+ if exc.code == 'task_document_empty':
67
+ return []
68
+ raise
69
+ rows: list[dict[str, object]] = []
70
+ for task in document.tasks:
71
+ for spec_id, spec_path in candidates.items():
72
+ pattern = re.compile(rf'{re.escape(spec_id)}#(RULE-[\w-]+)')
73
+ refs = sorted(set(pattern.findall(task.body)))
74
+ if not refs and spec_path not in task.body:
75
+ continue
76
+ for ref in refs or ['']:
77
+ rows.append({'task_path': path.relative_to(root).as_posix(), 'task_id': task.id,
78
+ 'spec_id': spec_id, 'status': task.status,
79
+ 'rule_ref': f'{spec_id}#{ref}' if ref else '',
80
+ 'source': 'task', 'archived': 'archived' in path.parts})
81
+ return rows
82
+
83
+
84
+ def scan_impacts(root: str, identities: dict[str, str], max_files: int = 5000,
85
+ max_depth: int = 20) -> dict[str, object]:
86
+ if max_files <= 0 or max_depth <= 0:
87
+ raise ValueError('impact limits must be positive')
88
+ project = Path(root).resolve()
89
+ tasks = project / '.code-flow/tasks'
90
+ rows: list[dict[str, object]] = []
91
+ errors: list[dict[str, str]] = []
92
+ def walk_error(exc: OSError) -> None:
93
+ errors.append({'path': str(exc.filename or tasks), 'message': str(exc)})
94
+ scanned = 0
95
+ truncated = False
96
+ for path in _files(tasks, max_depth, walk_error):
97
+ if path is None:
98
+ truncated = True
99
+ continue
100
+ if scanned >= max_files:
101
+ truncated = True
102
+ break
103
+ scanned += 1
104
+ try:
105
+ if path.is_symlink():
106
+ raise ValueError('symlink task file is not scanned')
107
+ references = (_context_references(path, project, identities) if path.name == 'spec-context.yml'
108
+ else _task_references(path, project, identities))
109
+ rows.extend(references)
110
+ except (OSError, UnicodeError, ValueError, ContextError) as exc:
111
+ errors.append({'path': path.relative_to(project).as_posix(), 'message': str(exc)})
112
+ return {'references': rows, 'scanned_files': scanned, 'truncated': truncated, 'errors': errors}
113
+
114
+
115
+ def inspect_impact(root: str, query: str, max_files: int = 5000, max_depth: int = 20) -> dict[str, object]:
116
+ spec_id, spec_path = _identity(root, query)
117
+ return {'spec_id': spec_id, 'spec_path': spec_path,
118
+ **scan_impacts(root, {spec_id: spec_path}, max_files, max_depth),
119
+ 'next': '修改后运行 code-flow spec refresh'}
@@ -241,8 +241,6 @@ def _parse_verifiers(
241
241
  rule = item.get("rule")
242
242
  kind = item.get("type")
243
243
  config = item.get("config")
244
- explicit_stage = "stage" in item
245
- stage = item.get("stage", "code")
246
244
  if not isinstance(rule, str) or not _RULE_ID_RE.fullmatch(rule):
247
245
  line = _verifier_field_line(raw, index, "rule")
248
246
  raise SpecMetadataError(path, f"verifiers[{index}].rule", line, "必须是稳定 RULE ID")
@@ -252,6 +250,11 @@ def _parse_verifiers(
252
250
  if not isinstance(config, dict):
253
251
  line = _verifier_field_line(raw, index, "config")
254
252
  raise SpecMetadataError(path, f"verifiers[{index}].config", line, "必须是 mapping")
253
+ explicit_stage = "stage" in item
254
+ # manual(人工验收)默认跟随 spec 声明的 review 阶段,落在需求级终验一次性确认;
255
+ # spec 未声明 review 时保持 code(逐任务确认);显式 stage 始终优先。
256
+ default_stage = "review" if kind == "manual" and "review" in stages else "code"
257
+ stage = item.get("stage", default_stage)
255
258
  if explicit_stage and (not isinstance(stage, str) or stage not in ALLOWED_VERIFIER_STAGES):
256
259
  line = _verifier_field_line(raw, index, "stage")
257
260
  raise SpecMetadataError(path, f"verifiers[{index}].stage", line, f"必须是 {sorted(ALLOWED_VERIFIER_STAGES)} 之一")
@@ -14,14 +14,17 @@ from typing import IO, Mapping, Optional, Sequence
14
14
 
15
15
  import yaml
16
16
 
17
- from cf_spec_context import BindingInput, bind_specs, new_context, save_context
18
- from cf_spec_metadata import SpecMetadata, SpecMetadataError, load_spec_metadata
19
- from cf_spec_resolver import SCOPE_PRIORITY, SpecCandidate
17
+ if __package__ in (None, ""):
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
19
+
20
+ from codeflow.cli.cf_spec_context import BindingInput, bind_specs, new_context, save_context
21
+ from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecMetadataError, load_spec_metadata
22
+ from codeflow.spec.cf_spec_resolver import SCOPE_PRIORITY, SpecCandidate
20
23
 
21
24
 
22
25
  TARGET_VERSION = "0.6.0"
23
26
  TARGET_SCHEMA = 1
24
- PACKAGE_ROOT = Path(__file__).resolve().parents[4]
27
+ PACKAGE_ROOT = Path(__file__).resolve().parents[6]
25
28
  _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
26
29
  _SUFFIXES = (".frontend.design.md", ".backend.design.md", ".design.md", ".prd.md", ".md")
27
30
 
@@ -425,17 +428,31 @@ def _write_target_config(staging: Path) -> None:
425
428
  path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
426
429
 
427
430
 
431
+ def _runtime_gitignore() -> tuple[str, list[str], str]:
432
+ path = Path(__file__).resolve().parents[2] / "runtime-resources.json"
433
+ data = json.loads(path.read_text(encoding="utf-8"))
434
+ block = data["gitignore"]
435
+ return block["start"], list(block["entries"]), block["end"]
436
+
437
+
428
438
  def _write_target_ignore(staging: Path) -> None:
429
439
  path = staging / ".code-flow/.gitignore"
430
440
  existing = path.read_text(encoding="utf-8") if path.exists() else ""
431
- block = (
432
- "# >>> code-flow:runtime schema=1\n.active-task.json\n.active-task.lock\n"
433
- ".catalog-state.json\nmigrations/\nspecs/_session/\n"
434
- "# <<< code-flow:runtime schema=1\n"
435
- )
441
+ # The packaged runtime-resources.json declaration is the single source shared
442
+ # with the CLI: migrate must not leave sessions/worktrees/backups/logs
443
+ # committable after an upgrade.
444
+ start, entries, end = _runtime_gitignore()
445
+ block = "\n".join([start, *entries, end]) + "\n"
436
446
  lines = [line for line in existing.splitlines() if line != ".inject-state"]
437
- if "# >>> code-flow:runtime schema=1" not in existing:
447
+ if start not in existing:
438
448
  path.write_text("\n".join(lines).rstrip() + "\n" + block, encoding="utf-8")
449
+ return
450
+ if end in lines:
451
+ missing = [entry for entry in entries if entry not in lines]
452
+ if missing:
453
+ at = lines.index(end)
454
+ lines[at:at] = missing
455
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
439
456
 
440
457
 
441
458
  def _target_manifest(staging: Path) -> list[dict[str, str]]: