@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,635 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic merges for shared demand-state files across parallel worktrees.
3
+
4
+ Parallel workers each finish in their own worktree; their branches touch the
5
+ same demand files (task markdown, ``spec-context.yml``,
6
+ ``.acceptance-manifest.json``), so a plain git rebase/merge always conflicts on
7
+ them. The rules here apply the documented deterministic priority
8
+ (cf-task:start 4.4) programmatically:
9
+
10
+ - task markdown: the merging TASK owns its section (branch wins); the
11
+ Acceptance Coverage table unions rows by scenario id with status priority;
12
+ every other section comes from main (the accumulating side).
13
+ - ``spec-context.yml``: bindings/rules union; only each rule's latest stage
14
+ state and latest evidence entry survive. When the post-merge input hash is
15
+ supplied, a verified state whose recorded hash no longer matches is
16
+ downgraded to ``stale`` (old proof is retained for audit).
17
+ - ``.acceptance-manifest.json``: checks union by id; the same check id with a
18
+ different command/cwd/timeout/env/inputs fails closed, as does a scenario
19
+ mapped to two different checks. Scenarios union by id (verified / higher
20
+ revision wins); run history unions and dedupes by (run_id, revision).
21
+
22
+ Only pure text/dict transformations live here; git orchestration stays in
23
+ ``cf_task_parallel.py``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from datetime import datetime, timezone
29
+ import json
30
+ import re
31
+ from typing import Mapping, Optional, Sequence
32
+
33
+ import yaml
34
+
35
+ from codeflow.acceptance.cf_acceptance_evidence import line_status
36
+
37
+
38
+ _COVERAGE_ROW_RE = re.compile(r"^\s*(?:\|\s*|[-*]\s+)([SEB]-\d+)(?=\s*[:|])")
39
+ _SECTION_SPLIT_RE = re.compile(r"(?m)(?=^## )")
40
+
41
+ # Coverage/contract cell statuses: a verified row always wins; deferred states
42
+ # outrank still-planned ones so done tasks keep their e2e_deferred/manual_pending.
43
+ _SCENARIO_PRIORITY = {
44
+ "verified": 100,
45
+ "e2e_deferred": 60,
46
+ "manual_pending": 60,
47
+ "unverified": 50,
48
+ "failed": 50,
49
+ "incomplete": 50,
50
+ "not_configured": 50,
51
+ "pending": 20,
52
+ "tbd": 20,
53
+ "planned": 10,
54
+ "designed": 5,
55
+ "": 0,
56
+ }
57
+
58
+ _RULE_STATUS_PRIORITY = {
59
+ "verified": 60,
60
+ "applied": 50,
61
+ "not_applicable": 40,
62
+ "waived": 40,
63
+ "unverified": 30,
64
+ "pending": 20,
65
+ "stale": 10,
66
+ "conflict": 10,
67
+ }
68
+
69
+ # Manifest v3 execution identity: equal IDs must describe the same execution.
70
+ _CHECK_IDENTITY_FIELDS = (
71
+ "command", "cwd", "timeout", "env", "env_scope", "preflight", "parallel_safe", "profile", "inputs",
72
+ )
73
+
74
+
75
+ class MergeConflictError(ValueError):
76
+ """Deterministic state merge refused because two sides disagree.
77
+
78
+ ``conflicts`` is a tuple of JSON-ready records so callers (parallel merge)
79
+ can surface the exact check/scenario and fields without parsing prose.
80
+ """
81
+
82
+ def __init__(self, code: str, message: str,
83
+ conflicts: Sequence[Mapping[str, object]] = ()) -> None:
84
+ super().__init__(message)
85
+ self.code = code
86
+ self.conflicts = tuple(dict(item) for item in conflicts)
87
+
88
+
89
+ def _as_dicts(value: object) -> list[dict]:
90
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
91
+
92
+
93
+ def _keyed(items: list[dict], key: str) -> dict:
94
+ result = {}
95
+ for item in items:
96
+ value = item.get(key)
97
+ if isinstance(value, str) and value:
98
+ result[value] = item
99
+ return result
100
+
101
+
102
+ # --- task markdown -----------------------------------------------------------
103
+
104
+
105
+ def _section_key(first_line: str) -> str:
106
+ heading = first_line.strip().lstrip("#").strip()
107
+ if heading.startswith("Acceptance Coverage"):
108
+ return "@coverage"
109
+ match = re.match(r"(TASK-\d+):", heading)
110
+ return match.group(1) if match else heading
111
+
112
+
113
+ def _split_sections(text: str) -> tuple[str, list[tuple[str, str]]]:
114
+ parts = _SECTION_SPLIT_RE.split(text)
115
+ preamble, blocks = parts[0], parts[1:]
116
+ sections: list[tuple[str, str]] = []
117
+ for block in blocks:
118
+ lines = block.splitlines()
119
+ if lines:
120
+ sections.append((_section_key(lines[0]), block))
121
+ return preamble, sections
122
+
123
+
124
+ def _scenario_status(line: str, scenario_id: str) -> str:
125
+ return line_status(line, scenario_id) or ""
126
+
127
+
128
+ def _merge_coverage(main_block: str, branch_block: str) -> str:
129
+ if not branch_block:
130
+ return main_block
131
+ lines = main_block.splitlines()
132
+ trailing = len(main_block) - len(main_block.rstrip("\n"))
133
+ row_index: dict[str, int] = {}
134
+ last_table = -1
135
+ for index, line in enumerate(lines):
136
+ scenario_id = _COVERAGE_ROW_RE.match(line)
137
+ if scenario_id:
138
+ row_index[scenario_id.group(1)] = index
139
+ if line.strip().startswith("|"):
140
+ last_table = index
141
+ insert_at = last_table if last_table >= 0 else len(lines) - 1
142
+ for line in branch_block.splitlines():
143
+ match = _COVERAGE_ROW_RE.match(line)
144
+ if not match:
145
+ continue
146
+ scenario_id = match.group(1)
147
+ if scenario_id in row_index:
148
+ current = lines[row_index[scenario_id]]
149
+ if _SCENARIO_PRIORITY.get(_scenario_status(line, scenario_id), 0) > \
150
+ _SCENARIO_PRIORITY.get(_scenario_status(current, scenario_id), 0):
151
+ lines[row_index[scenario_id]] = line
152
+ else:
153
+ insert_at += 1
154
+ lines.insert(insert_at, line)
155
+ row_index[scenario_id] = insert_at
156
+ return "\n".join(lines) + "\n" * trailing
157
+
158
+
159
+ def merge_task_markdown(main_text: str, branch_text: str, task_id: str) -> str:
160
+ """Merge the task file: own TASK section from branch, coverage union, rest main."""
161
+ main_preamble, main_sections = _split_sections(main_text)
162
+ _, branch_sections = _split_sections(branch_text)
163
+ branch_map = dict(branch_sections)
164
+ ordered: list[str] = []
165
+ seen: set[str] = set()
166
+ for key, block in main_sections:
167
+ seen.add(key)
168
+ if key == task_id:
169
+ block = branch_map.get(task_id, block)
170
+ elif key == "@coverage":
171
+ block = _merge_coverage(block, branch_map.get("@coverage", ""))
172
+ ordered.append(block)
173
+ for key, block in branch_sections:
174
+ if key not in seen:
175
+ ordered.append(block)
176
+ return main_preamble + "".join(ordered)
177
+
178
+
179
+ # --- spec-context.yml --------------------------------------------------------
180
+
181
+
182
+ def _merge_sources(main: object, branch: object) -> list[dict]:
183
+ merged, seen = [], set()
184
+ for item in _as_dicts(main) + _as_dicts(branch):
185
+ key = (item.get("type"), item.get("ref"))
186
+ if key not in seen:
187
+ seen.add(key)
188
+ merged.append(item)
189
+ return merged
190
+
191
+
192
+ def _merge_refs(main: object, branch: object) -> list[dict]:
193
+ merged, seen = [], set()
194
+ for item in _as_dicts(main) + _as_dicts(branch):
195
+ key = (item.get("artifact"), item.get("section_id"),
196
+ item.get("item_id"), item.get("artifact_sha256"))
197
+ if key not in seen:
198
+ seen.add(key)
199
+ merged.append(item)
200
+ return merged
201
+
202
+
203
+ def _evidence_time(item: Mapping[str, object]) -> float:
204
+ value = item.get("executed_at")
205
+ if not isinstance(value, str) or not value.strip():
206
+ return 0.0
207
+ try:
208
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
209
+ except ValueError:
210
+ return 0.0
211
+ if parsed.tzinfo is None:
212
+ parsed = parsed.replace(tzinfo=timezone.utc)
213
+ return parsed.timestamp()
214
+
215
+
216
+ def _latest_evidence(status: Mapping[str, object]) -> Optional[dict]:
217
+ evidence = _as_dicts(status.get("evidence"))
218
+ if not evidence:
219
+ return None
220
+ # Timestamp wins; equal/missing timestamps keep list order (last entry).
221
+ return max(enumerate(evidence), key=lambda pair: (_evidence_time(pair[1]), pair[0]))[1]
222
+
223
+
224
+ def _latest_status_time(status: Mapping[str, object]) -> float:
225
+ evidence = _as_dicts(status.get("evidence"))
226
+ return max((_evidence_time(item) for item in evidence), default=0.0)
227
+
228
+
229
+ def _choose_stage(main: object, branch: object) -> dict:
230
+ if not isinstance(main, dict):
231
+ return dict(branch) if isinstance(branch, dict) else {}
232
+ if not isinstance(branch, dict):
233
+ return dict(main)
234
+ main_time = _latest_status_time(main)
235
+ branch_time = _latest_status_time(branch)
236
+ if branch_time != main_time:
237
+ return dict(branch if branch_time > main_time else main)
238
+ main_status = str(main.get("status", ""))
239
+ branch_status = str(branch.get("status", ""))
240
+ if _RULE_STATUS_PRIORITY.get(branch_status, 0) > _RULE_STATUS_PRIORITY.get(main_status, 0):
241
+ return dict(branch)
242
+ return dict(main)
243
+
244
+
245
+ def _merge_stage(main: object, branch: object, input_hash: Optional[str]) -> object:
246
+ stage = _choose_stage(main, branch)
247
+ if not stage:
248
+ return stage
249
+ if isinstance(main, dict) and isinstance(branch, dict):
250
+ stage["refs"] = _merge_refs(main.get("refs"), branch.get("refs"))
251
+ stage["decision"] = main.get("decision") or branch.get("decision")
252
+ stage["evidence"] = _single_latest_evidence(stage, main, branch)
253
+ else:
254
+ stage["evidence"] = _single_latest_evidence(stage, stage, stage)
255
+ return _downgrade_stale(stage, input_hash)
256
+
257
+
258
+ def _single_latest_evidence(chosen: Mapping[str, object], main: Mapping[str, object],
259
+ branch: Mapping[str, object]) -> list[dict]:
260
+ latest = _latest_evidence(chosen)
261
+ if latest is None:
262
+ latest = _latest_evidence(main) or _latest_evidence(branch)
263
+ return [latest] if latest is not None else []
264
+
265
+
266
+ def _evidence_input_hash(evidence: Mapping[str, object]) -> Optional[str]:
267
+ for key in ("input_hash", "diff_sha256"):
268
+ value = evidence.get(key)
269
+ if isinstance(value, str) and value:
270
+ return value
271
+ return None
272
+
273
+
274
+ def _downgrade_stale(stage: dict, input_hash: Optional[str]) -> dict:
275
+ """A verified proof is only valid for the input hash it ran against."""
276
+ if not input_hash or stage.get("status") != "verified":
277
+ return stage
278
+ latest = _latest_evidence(stage)
279
+ recorded = _evidence_input_hash(latest) if latest is not None else None
280
+ if recorded != input_hash:
281
+ stage["status"] = "stale"
282
+ return stage
283
+
284
+
285
+ def _merge_stage_status(main: object, branch: object, input_hash: Optional[str]) -> dict:
286
+ main_map = main if isinstance(main, dict) else {}
287
+ branch_map = branch if isinstance(branch, dict) else {}
288
+ return {
289
+ stage: _merge_stage(main_map.get(stage), branch_map.get(stage), input_hash)
290
+ for stage in sorted(set(main_map) | set(branch_map))
291
+ }
292
+
293
+
294
+ def _merge_rule(main: dict, branch: dict, input_hash: Optional[str]) -> dict:
295
+ merged = dict(main)
296
+ merged["stage_status"] = _merge_stage_status(
297
+ main.get("stage_status"), branch.get("stage_status"), input_hash
298
+ )
299
+ return merged
300
+
301
+
302
+ def _merge_by_ref(main: object, branch: object, input_hash: Optional[str]) -> list[dict]:
303
+ main_by = _keyed(_as_dicts(main), "ref")
304
+ branch_by = _keyed(_as_dicts(branch), "ref")
305
+ merged: list[dict] = []
306
+ for ref in sorted(set(main_by) | set(branch_by)):
307
+ a, b = main_by.get(ref), branch_by.get(ref)
308
+ if a and b:
309
+ merged.append(_merge_rule(a, b, input_hash))
310
+ else:
311
+ merged.append(a or b)
312
+ return merged
313
+
314
+
315
+ def _merge_binding(main: dict, branch: dict, input_hash: Optional[str]) -> dict:
316
+ merged = dict(main)
317
+ merged["rules"] = _merge_by_ref(main.get("rules"), branch.get("rules"), input_hash)
318
+ return merged
319
+
320
+
321
+ def _merge_bindings(main: object, branch: object, input_hash: Optional[str]) -> list[dict]:
322
+ main_by = _keyed(_as_dicts(main), "spec_id")
323
+ branch_by = _keyed(_as_dicts(branch), "spec_id")
324
+ merged: list[dict] = []
325
+ for spec_id in sorted(set(main_by) | set(branch_by)):
326
+ a, b = main_by.get(spec_id), branch_by.get(spec_id)
327
+ if a and b:
328
+ merged.append(_merge_binding(a, b, input_hash))
329
+ else:
330
+ merged.append(a or b)
331
+ return merged
332
+
333
+
334
+ def _each_stage(doc: Mapping[str, object]):
335
+ for binding in _as_dicts(doc.get("bindings")):
336
+ for rule in _as_dicts(binding.get("rules")):
337
+ stage_status = rule.get("stage_status")
338
+ if not isinstance(stage_status, dict):
339
+ continue
340
+ for stage, status in list(stage_status.items()):
341
+ if isinstance(status, dict):
342
+ yield stage_status, stage, status
343
+
344
+
345
+ def _compact_context(doc: dict) -> dict:
346
+ """Keep only the latest evidence entry for every rule/stage."""
347
+ for stage_status, stage, status in _each_stage(doc):
348
+ stage_status[stage] = {
349
+ **status,
350
+ "evidence": _single_latest_evidence(status, status, status),
351
+ }
352
+ return doc
353
+
354
+
355
+ def _downgrade_context(doc: dict, input_hash: Optional[str]) -> dict:
356
+ if not input_hash:
357
+ return doc
358
+ for stage_status, stage, status in _each_stage(doc):
359
+ stage_status[stage] = _downgrade_stale(dict(status), input_hash)
360
+ return doc
361
+
362
+
363
+ def merge_context_docs(main: Mapping[str, object], branch: Mapping[str, object],
364
+ input_hash: Optional[str] = None) -> dict:
365
+ """Union metadata but keep only each rule's latest state/evidence.
366
+
367
+ ``input_hash`` is the post-merge execution input fingerprint. Verified
368
+ states whose recorded hash differs are downgraded to ``stale``.
369
+ """
370
+ merged = dict(main)
371
+ merged["updated_at"] = max(
372
+ str(main.get("updated_at", "")), str(branch.get("updated_at", ""))
373
+ )
374
+ merged["sources"] = _merge_sources(main.get("sources"), branch.get("sources"))
375
+ merged["bindings"] = _merge_bindings(main.get("bindings"), branch.get("bindings"), None)
376
+ _compact_context(merged)
377
+ return _downgrade_context(merged, input_hash)
378
+
379
+
380
+ def merge_context_text(main_text: str, branch_text: str,
381
+ input_hash: Optional[str] = None) -> str:
382
+ try:
383
+ main_doc = yaml.safe_load(main_text) or {}
384
+ branch_doc = yaml.safe_load(branch_text) or {}
385
+ except yaml.YAMLError as exc:
386
+ raise ValueError(f"spec-context.yml 解析失败: {exc}") from exc
387
+ if not isinstance(main_doc, dict) or not isinstance(branch_doc, dict):
388
+ raise ValueError("spec-context.yml must be a mapping on both sides")
389
+ merged = merge_context_docs(main_doc, branch_doc, input_hash)
390
+ return yaml.safe_dump(merged, sort_keys=False, allow_unicode=True)
391
+
392
+
393
+ def stale_context_text(text: str, input_hash: str) -> tuple[str, list[str]]:
394
+ """Downgrade verified rules whose evidence hash no longer matches."""
395
+ try:
396
+ doc = yaml.safe_load(text) or {}
397
+ except yaml.YAMLError as exc:
398
+ raise ValueError(f"spec-context.yml 解析失败: {exc}") from exc
399
+ if not isinstance(doc, dict):
400
+ raise ValueError("spec-context.yml must be a mapping")
401
+ before = _stale_statuses(doc)
402
+ if not input_hash:
403
+ return text, []
404
+ _downgrade_context(doc, input_hash)
405
+ stale = sorted(set(_stale_statuses(doc)) - set(before))
406
+ if not stale:
407
+ return text, []
408
+ return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True), stale
409
+
410
+
411
+ def _stale_statuses(doc: Mapping[str, object]) -> list[str]:
412
+ refs: list[str] = []
413
+ for binding in _as_dicts(doc.get("bindings")):
414
+ spec_id = str(binding.get("spec_id", ""))
415
+ for rule in _as_dicts(binding.get("rules")):
416
+ for stage, status in (rule.get("stage_status") or {}).items():
417
+ if isinstance(status, Mapping) and status.get("status") == "stale":
418
+ refs.append(f"{spec_id}#{rule.get('ref')}.{stage}")
419
+ return refs
420
+
421
+
422
+ # --- .acceptance-manifest.json ----------------------------------------------
423
+
424
+
425
+ def _normalized_check_field(field: str, value: object) -> object:
426
+ if field == "timeout":
427
+ return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else value
428
+ if field == "inputs":
429
+ return sorted(str(item) for item in value) if isinstance(value, list) else value
430
+ if field == "preflight":
431
+ return sorted(str(item) for item in value) if isinstance(value, list) else value
432
+ if field == "command":
433
+ return [str(item) for item in value] if isinstance(value, list) else value
434
+ return value
435
+
436
+
437
+ def _check_identity(check: Mapping[str, object]) -> dict[str, object]:
438
+ defaults = {
439
+ "command": [], "cwd": ".", "timeout": 60, "env": "none",
440
+ "env_scope": "shared", "preflight": [], "parallel_safe": False,
441
+ "profile": "changed", "inputs": [],
442
+ }
443
+ return {
444
+ field: _normalized_check_field(field, check.get(field, defaults[field]))
445
+ for field in _CHECK_IDENTITY_FIELDS
446
+ }
447
+
448
+
449
+ def _check_conflict(check_id: str, main: Mapping[str, object],
450
+ branch: Mapping[str, object]) -> Optional[dict[str, object]]:
451
+ main_identity = _check_identity(main)
452
+ branch_identity = _check_identity(branch)
453
+ fields = [
454
+ field for field in _CHECK_IDENTITY_FIELDS
455
+ if main_identity.get(field) != branch_identity.get(field)
456
+ ]
457
+ if not fields:
458
+ return None
459
+ return {
460
+ "check_id": check_id,
461
+ "fields": fields,
462
+ "main": {field: main_identity.get(field) for field in fields},
463
+ "branch": {field: branch_identity.get(field) for field in fields},
464
+ }
465
+
466
+
467
+ def _check_conflicts(main: object, branch: object) -> list[dict[str, object]]:
468
+ main_by = _keyed(_as_dicts(main), "id")
469
+ branch_by = _keyed(_as_dicts(branch), "id")
470
+ conflicts: list[dict[str, object]] = []
471
+ for check_id in sorted(set(main_by) & set(branch_by)):
472
+ conflict = _check_conflict(check_id, main_by[check_id], branch_by[check_id])
473
+ if conflict is not None:
474
+ conflicts.append(conflict)
475
+ # A check id can change when config changes; the same command identity must
476
+ # still not silently take two different environments.
477
+ main_cmd = {
478
+ (tuple(item.get("command") or []), str(item.get("cwd", ".")), float(item.get("timeout", 60))): item
479
+ for item in _as_dicts(main)
480
+ }
481
+ for item in _as_dicts(branch):
482
+ key = (tuple(item.get("command") or []), str(item.get("cwd", ".")), float(item.get("timeout", 60)))
483
+ other = main_cmd.get(key)
484
+ if other is None or str(other.get("id")) == str(item.get("id")):
485
+ continue
486
+ conflict = _check_conflict(str(other.get("id", "")), other, item)
487
+ if conflict is not None:
488
+ conflicts.append(conflict)
489
+ return conflicts
490
+
491
+
492
+ def compare_manifest_checks(main: Mapping[str, object], branch: Mapping[str, object]) -> list[dict[str, object]]:
493
+ """Report conflicting check contracts before merging two locked manifests."""
494
+ return _check_conflicts(main.get("checks"), branch.get("checks"))
495
+
496
+
497
+ def _merge_checks(main: object, branch: object) -> list[dict]:
498
+ main_rows = _as_dicts(main)
499
+ branch_by = _keyed(_as_dicts(branch), "id")
500
+ merged: list[dict] = []
501
+ seen: set = set()
502
+ conflicts: list[dict[str, object]] = []
503
+ for row in main_rows:
504
+ check_id = row.get("id")
505
+ seen.add(check_id)
506
+ other = branch_by.get(check_id) if isinstance(check_id, str) else None
507
+ if other is not None:
508
+ conflict = _check_conflict(str(check_id), row, other)
509
+ if conflict is not None:
510
+ conflicts.append(conflict)
511
+ merged.append({**other, **row})
512
+ else:
513
+ merged.append(row)
514
+ for row in _as_dicts(branch):
515
+ if row.get("id") not in seen:
516
+ merged.append(row)
517
+ if conflicts:
518
+ detail = ", ".join(
519
+ f"{item['check_id']}({','.join(str(field) for field in item['fields'])})"
520
+ for item in conflicts
521
+ )
522
+ raise MergeConflictError(
523
+ "check_conflict",
524
+ f"acceptance manifest check 冲突(同 ID 字段不一致): {detail}",
525
+ conflicts,
526
+ )
527
+ return merged
528
+
529
+
530
+ def _scenario_conflicts(main: object, branch: object) -> list[dict[str, object]]:
531
+ main_by = _keyed(_as_dicts(main), "id")
532
+ branch_by = _keyed(_as_dicts(branch), "id")
533
+ conflicts: list[dict[str, object]] = []
534
+ for scenario_id in sorted(set(main_by) & set(branch_by)):
535
+ main_check = main_by[scenario_id].get("check")
536
+ branch_check = branch_by[scenario_id].get("check")
537
+ if (
538
+ isinstance(main_check, str) and main_check
539
+ and isinstance(branch_check, str) and branch_check
540
+ and main_check != branch_check
541
+ ):
542
+ conflicts.append({
543
+ "scenario_id": scenario_id,
544
+ "fields": ["check"],
545
+ "main": main_check,
546
+ "branch": branch_check,
547
+ })
548
+ return conflicts
549
+
550
+
551
+ def _merge_runs(main: object, branch: object) -> list[dict]:
552
+ merged, seen = [], set()
553
+ for item in _as_dicts(main) + _as_dicts(branch):
554
+ key = (item.get("run_id"), item.get("revision"))
555
+ if key not in seen:
556
+ seen.add(key)
557
+ merged.append(item)
558
+ merged.sort(key=lambda item: (int(item.get("revision", 0) or 0), str(item.get("run_id", ""))))
559
+ return merged
560
+
561
+
562
+ def _choose_scenario(main: dict, branch: dict) -> dict:
563
+ main_status = str(main.get("status", ""))
564
+ branch_status = str(branch.get("status", ""))
565
+ if main_status == "verified" and branch_status != "verified":
566
+ return main
567
+ if branch_status == "verified" and main_status != "verified":
568
+ return branch
569
+ main_revision = int(main.get("revision", 0) or 0)
570
+ branch_revision = int(branch.get("revision", 0) or 0)
571
+ return main if main_revision >= branch_revision else branch
572
+
573
+
574
+ def _merge_scenario(main: dict, branch: dict) -> dict:
575
+ merged = dict(_choose_scenario(main, branch))
576
+ merged["runs"] = _merge_runs(main.get("runs"), branch.get("runs"))
577
+ if not merged.get("check"):
578
+ merged["check"] = main.get("check") or branch.get("check")
579
+ return merged
580
+
581
+
582
+ def _merge_scenarios(main: object, branch: object) -> list[dict]:
583
+ main_rows = _as_dicts(main)
584
+ branch_by = _keyed(_as_dicts(branch), "id")
585
+ merged: list[dict] = []
586
+ seen: set = set()
587
+ for row in main_rows:
588
+ scenario_id = row.get("id")
589
+ seen.add(scenario_id)
590
+ if scenario_id in branch_by:
591
+ merged.append(_merge_scenario(row, branch_by[scenario_id]))
592
+ else:
593
+ merged.append(row)
594
+ for row in _as_dicts(branch):
595
+ if row.get("id") not in seen:
596
+ merged.append(row)
597
+ return merged
598
+
599
+
600
+ def _merged_schema(main: Mapping[str, object], branch: Mapping[str, object]) -> object:
601
+ schema = main.get("schema")
602
+ other = branch.get("schema")
603
+ if isinstance(schema, int) and isinstance(other, int):
604
+ return max(schema, other)
605
+ return schema
606
+
607
+
608
+ def merge_manifest_docs(main: Mapping[str, object], branch: Mapping[str, object]) -> dict:
609
+ merged_checks = _merge_checks(main.get("checks"), branch.get("checks")) if ("checks" in main or "checks" in branch) else None
610
+ conflicts = _scenario_conflicts(main.get("scenarios"), branch.get("scenarios"))
611
+ if conflicts:
612
+ detail = ", ".join(
613
+ f"{item['scenario_id']}({item['main']}->{item['branch']})" for item in conflicts
614
+ )
615
+ raise MergeConflictError(
616
+ "scenario_check_conflict",
617
+ f"scenario→check 映射冲突: {detail}",
618
+ conflicts,
619
+ )
620
+ merged = dict(main)
621
+ if "schema" in main or "schema" in branch:
622
+ merged["schema"] = _merged_schema(main, branch)
623
+ if "checks" in main or "checks" in branch:
624
+ merged["checks"] = _merge_checks(main.get("checks"), branch.get("checks"))
625
+ merged["scenarios"] = _merge_scenarios(main.get("scenarios"), branch.get("scenarios"))
626
+ return merged
627
+
628
+
629
+ def merge_manifest_text(main_text: str, branch_text: str) -> str:
630
+ main_doc = json.loads(main_text)
631
+ branch_doc = json.loads(branch_text)
632
+ if not isinstance(main_doc, dict) or not isinstance(branch_doc, dict):
633
+ raise ValueError("acceptance manifest must be an object on both sides")
634
+ merged = merge_manifest_docs(main_doc, branch_doc)
635
+ return json.dumps(merged, ensure_ascii=False, indent=2) + "\n"