workflow-loop 0.1.0__py3-none-any.whl

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 (60) hide show
  1. workflow_loop/__init__.py +6 -0
  2. workflow_loop/acceptance_records.py +338 -0
  3. workflow_loop/artifact_paths.py +278 -0
  4. workflow_loop/artifact_validation.py +1738 -0
  5. workflow_loop/bug_record.py +203 -0
  6. workflow_loop/cli.py +3257 -0
  7. workflow_loop/data/Standardized_Repository/acceptance/acceptance.md +119 -0
  8. workflow_loop/data/Standardized_Repository/acceptance/acceptance_plan.md +105 -0
  9. workflow_loop/data/Standardized_Repository/code_design/code_design.md +204 -0
  10. workflow_loop/data/Standardized_Repository/code_design/project_design_init.md +152 -0
  11. workflow_loop/data/Standardized_Repository/code_design/revise_code_design.md +32 -0
  12. workflow_loop/data/Standardized_Repository/code_design/update_code_design.md +94 -0
  13. workflow_loop/data/Standardized_Repository/global/document_writing.md +77 -0
  14. workflow_loop/data/Standardized_Repository/global/workflow_lifecycle.md +91 -0
  15. workflow_loop/data/Standardized_Repository/impl/code_implementation.md +85 -0
  16. workflow_loop/data/Standardized_Repository/impl/impl.md +164 -0
  17. workflow_loop/data/Standardized_Repository/qa/test.md +167 -0
  18. workflow_loop/data/Standardized_Repository/qa/test_code.md +121 -0
  19. workflow_loop/data/Standardized_Repository/qa/test_code_implementation.md +67 -0
  20. workflow_loop/data/Standardized_Repository/qa/test_plan.md +160 -0
  21. workflow_loop/data/Standardized_Repository/reproduce/reproduce.md +60 -0
  22. workflow_loop/data/Standardized_Repository/spec/spec.md +138 -0
  23. workflow_loop/data/Standardized_Repository/spike/spike.md +236 -0
  24. workflow_loop/data/Template_Repository/acceptance/acceptance_plan.md +142 -0
  25. workflow_loop/data/Template_Repository/acceptance/acceptance_result.md +108 -0
  26. workflow_loop/data/Template_Repository/code_design/code_design.md +260 -0
  27. workflow_loop/data/Template_Repository/code_design/project_design_init_evidence.md +39 -0
  28. workflow_loop/data/Template_Repository/impl/impl.md +112 -0
  29. workflow_loop/data/Template_Repository/qa/test.md +102 -0
  30. workflow_loop/data/Template_Repository/qa/test_plan.md +100 -0
  31. workflow_loop/data/Template_Repository/reproduce/reproduce.md +82 -0
  32. workflow_loop/data/Template_Repository/spec/spec.md +222 -0
  33. workflow_loop/data/Template_Repository/spike/spike.md +135 -0
  34. workflow_loop/installer.py +632 -0
  35. workflow_loop/journal.py +78 -0
  36. workflow_loop/path_composer.py +152 -0
  37. workflow_loop/process_runner.py +176 -0
  38. workflow_loop/project.py +397 -0
  39. workflow_loop/role_doc.py +133 -0
  40. workflow_loop/rollback.py +1738 -0
  41. workflow_loop/spike_validation.py +379 -0
  42. workflow_loop/stage_materials.py +169 -0
  43. workflow_loop/stages/__init__.py +45 -0
  44. workflow_loop/stages/base.py +164 -0
  45. workflow_loop/stages/stages.py +1191 -0
  46. workflow_loop/state.py +582 -0
  47. workflow_loop/test_entry.py +123 -0
  48. workflow_loop/test_execution.py +619 -0
  49. workflow_loop/test_mapping.py +568 -0
  50. workflow_loop/test_runner.py +134 -0
  51. workflow_loop/topic.py +114 -0
  52. workflow_loop/topic_relations.py +202 -0
  53. workflow_loop/traceability.py +533 -0
  54. workflow_loop/verification.py +971 -0
  55. workflow_loop-0.1.0.dist-info/METADATA +187 -0
  56. workflow_loop-0.1.0.dist-info/RECORD +60 -0
  57. workflow_loop-0.1.0.dist-info/WHEEL +5 -0
  58. workflow_loop-0.1.0.dist-info/entry_points.txt +2 -0
  59. workflow_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. workflow_loop-0.1.0.dist-info/top_level.txt +1 -0
workflow_loop/state.py ADDED
@@ -0,0 +1,582 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from dataclasses import dataclass, field, asdict
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ # state.json 的相对路径(相对于项目根)
9
+ # 放在 .workflow_loop/ 下,和 journal.jsonl、project.json 同级
10
+ STATE_FILE = os.path.join(".workflow_loop", "state.json")
11
+
12
+
13
+ # 单个 stage 的 3 道闸状态(门禁策略第一版:顺序硬性)
14
+ # 三道闸依次推进:讨论完毕 → 代码校验 → 用户确认
15
+ # 跳步直接报错,不能跳过前一道直接调后一道
16
+ @dataclass
17
+ class GateState:
18
+ # 第 1 道闸:用户确认讨论完毕(AI 和用户已经用提示词充分讨论)
19
+ discussion_complete: bool = False
20
+ # 第 2 道闸:代码侧校验通过(文件存在 / 内容哈希匹配等)
21
+ code_validated: bool = False
22
+ # 第 3 道闸:用户确认产出写完了(最终由用户拍板,AI 不得代确认)
23
+ user_confirmed: bool = False
24
+
25
+
26
+ @dataclass
27
+ class TestExecutionRecord:
28
+ """一个测试项本次成功执行后的机器记录。
29
+
30
+ 这里保存程序判断"这次是否真的执行过"的机器事实:命令、工作目录、超时、
31
+ 时间、时长、退出码、输出摘要、输出哈希和执行时的代码版本。
32
+ 可读的测试结论和证据由主题测试结果文档记录,并逐项引用这里的记录编号。
33
+ """
34
+
35
+ test_entries: list[str] = field(default_factory=list)
36
+ command: list[str] = field(default_factory=list)
37
+ # 项目内工作目录(相对项目根;空表示项目根)
38
+ cwd: str = ""
39
+ timeout_seconds: int | None = None
40
+ started_at: str | None = None
41
+ finished_at: str | None = None
42
+ duration_seconds: float | None = None
43
+ exit_code: int | None = None
44
+ status: str = "passed"
45
+ environment: dict[str, str] = field(default_factory=dict)
46
+ code_snapshot_hash: str | None = None
47
+ test_code_hash: str | None = None
48
+ # 机器记录编号:验收记录和结果文档用它引用这次精确执行
49
+ record_id: str | None = None
50
+ # 有界输出事实:末尾摘要、完整输出哈希和字节数
51
+ output_tail: str = ""
52
+ output_sha256: str | None = None
53
+ output_bytes: int | None = None
54
+ platform: str = ""
55
+ executable: str = ""
56
+
57
+
58
+ @dataclass
59
+ class TestTaskState:
60
+ """测试执行前登记的一个测试项任务。"""
61
+
62
+ test_entries: list[str] = field(default_factory=list)
63
+ command: list[str] = field(default_factory=list)
64
+ # 项目内工作目录(相对项目根;空表示项目根)
65
+ cwd: str = ""
66
+ dependencies: list[str] = field(default_factory=list)
67
+ timeout_seconds: int = 600
68
+ status: str = "pending"
69
+ prepared_at: str | None = None
70
+ last_error: str | None = None
71
+ current_record: TestExecutionRecord | None = None
72
+
73
+
74
+ @dataclass
75
+ class AcceptanceCriterionRecord:
76
+ """一条验收条件当前仍有效的验收记录。"""
77
+
78
+ topic: str = ""
79
+ criterion_id: str = ""
80
+ method: str = ""
81
+ result: str = "passed"
82
+ actual_result: str = ""
83
+ user_answer: str | None = None
84
+ evidence: str = ""
85
+ confirmed_at: str | None = None
86
+ acceptance_plan_hash: str | None = None
87
+ impl_hash: str | None = None
88
+ test_result_hash: str | None = None
89
+ test_ids: list[str] = field(default_factory=list)
90
+ # 作为验收依据的机器测试记录编号列表;自动化或混合验收指向精确执行事实
91
+ test_record_ids: list[str] = field(default_factory=list)
92
+ record_id: str | None = None
93
+
94
+
95
+ # 单个 stage 的完整状态
96
+ # 嵌套在 WorkflowState.stages 字典里,key 是 stage 名(如 "spec" / "impl")
97
+ @dataclass
98
+ class StageState:
99
+ # stage 生命周期状态:pending(没开始)→ in_progress(AI 在做)→ gated(等门禁)→ done(过了门禁)
100
+ status: str = "pending"
101
+ # 期望产出的文件路径列表(相对项目根),可能多个(如 spec/产品总说明.md + spec/功能_*.md)
102
+ artifact_paths: list[str] = field(default_factory=list)
103
+ # 产出文件首次出现的时间戳(ISO 8601 UTC),用于 journal 追溯
104
+ artifact_produced_at: str | None = None
105
+ # 用户确认讨论完成时保存的文件基线时间;用于判断本阶段是否真的修改了产物
106
+ artifact_baseline_captured_at: str | None = None
107
+ # 基线文件哈希,key 是项目根下的相对路径,value 是 SHA256;文件当时不存在时为 None
108
+ artifact_baseline_hashes: dict[str, str | None] = field(default_factory=dict)
109
+ # impl 阶段进入时的代码快照哈希;用于阻止实施计划确认前修改代码
110
+ code_baseline_hash: str | None = None
111
+ # test_code 阶段进入时的测试代码快照哈希;用于确认本阶段确实新增或修改了测试代码
112
+ test_code_baseline_hash: str | None = None
113
+ # test_code 阶段进入时的非测试代码快照哈希;用于阻止测试阶段偷偷修改产品代码
114
+ non_test_code_baseline_hash: str | None = None
115
+ # 用户确认代码在实施计划确认前已经存在时,保存被确认的代码快照哈希
116
+ existing_code_accepted_hash: str | None = None
117
+ # 上游变化后,用户确认当前测试代码仍符合最新测试计划时保存的测试代码快照哈希
118
+ existing_test_code_accepted_hash: str | None = None
119
+ # workflow discuss 读取的全局规范、阶段模板和补充规范的组合哈希。
120
+ # 材料变化后,旧的讨论确认自动失效,必须重新阅读后再过门禁。
121
+ discussion_material_hash: str | None = None
122
+ # impl 阶段用户最后确认的实施计划哈希;计划调整重新确认后更新
123
+ plan_confirmed_hash: str | None = None
124
+ # test_execution 阶段的任务登记:第一层 key 是主题,第二层 key 是 TC 编号。
125
+ test_tasks: dict[str, dict[str, TestTaskState]] = field(default_factory=dict)
126
+ # topic_acceptance 阶段的当前有效验收记录:第一层 key 是主题,第二层 key 是 AC 编号。
127
+ acceptance_records: dict[str, dict[str, AcceptanceCriterionRecord]] = field(default_factory=dict)
128
+ # 该 stage 的 3 道闸状态,嵌套 dataclass
129
+ gate: GateState = field(default_factory=GateState)
130
+
131
+
132
+ # 架构文档双阶段完成度标记(CONTEXT.md "Architecture Gate Marks")
133
+ # 同一份 spec/代码架构设计.md 的两种完成度,不是两个无关文件
134
+ @dataclass
135
+ class ArchitectureState:
136
+ # 初步架构完成:前段架构 stage(code_design/revise_code_design/project_design_init)--confirmed 后置 true
137
+ preliminary_done: bool = False
138
+ # 最终设计同步完成:末段 update_code_design --confirmed 后置 true
139
+ # 文件存在只是必要条件,不得因已存在而自动跳过最终设计同步
140
+ detailed_done: bool = False
141
+
142
+
143
+ @dataclass
144
+ class RecoveryContext:
145
+ """上游变化或用户主动退回后,解释为什么重新经过当前阶段。"""
146
+
147
+ # 引发退回的阶段,例如 test_plan(测试计划阶段)
148
+ source_stage: str | None = None
149
+ # 用户确认的返回目标阶段(workflow return --to 的目标)
150
+ return_target: str | None = None
151
+ # 发生退回的具体原因
152
+ reason: str | None = None
153
+ # 需要重新确认或重新执行的阶段,按原路径顺序保存
154
+ affected_stages: list[str] = field(default_factory=list)
155
+ # 用户确认的直接受影响主题;空列表表示未按主题区分
156
+ affected_topics: list[str] = field(default_factory=list)
157
+ # 发生时间 ISO 8601 UTC
158
+ created_at: str | None = None
159
+
160
+
161
+ # Verification Invalidation 的哈希绑定(CONTEXT.md "Verification Invalidation")
162
+ # 通过状态只对绑定的上游内容有效;上游变化时下游门禁清零
163
+ @dataclass
164
+ class VerificationState:
165
+ # 实施代码 + impl/ 下全部实施记录的 SHA256 哈希
166
+ # 在 gate impl --confirmed 时记录;测试代码或测试结果变化不会改写它
167
+ impl_hash: str | None = None
168
+ # qa/<主题文件标识>_测试计划.md 内容的 SHA256 哈希
169
+ # 在 gate test_plan --confirmed 时记录;变化时使主题执行、最终全量回归和整体验收失效
170
+ test_plan_hash: str | None = None
171
+ # acceptance/索引.md 和 acceptance/<主题文件标识>_验收计划.md 内容的 SHA256 哈希
172
+ # 在 gate acceptance_plan --confirmed 时记录;变化时退回测试计划和后续阶段
173
+ acceptance_plan_hash: str | None = None
174
+ # test_code 确认后的测试代码、测试配置和统一测试入口哈希
175
+ # 在 gate test_code --confirmed 时记录;变化时退回 test_code
176
+ test_code_hash: str | None = None
177
+ # qa/<主题文件标识>_测试结果.md 内容的 SHA256 哈希
178
+ # 在 gate test_execution --confirmed 时记录;变化时使主题验收及后续阶段失效
179
+ test_result_hash: str | None = None
180
+ # acceptance/<主题文件标识>_验收结果.md 内容的 SHA256 哈希
181
+ # 在 gate topic_acceptance --confirmed 时记录;变化时使最终回归及后续阶段失效
182
+ acceptance_result_hash: str | None = None
183
+ # 最终回归执行状态的 SHA256 哈希
184
+ # 在 gate regression_test --confirmed 时记录;代码或状态变化时清零后续验收
185
+ regression_test_result_hash: str | None = None
186
+
187
+
188
+ # 最终全量回归状态;由 regression_test 阶段自动执行项目统一测试入口写入。
189
+ # 修改前全量测试基线(旧 TestBaselineState)已经删除:读取旧状态时忽略该字段,
190
+ # 不再写回;实施前没有任何全量测试执行路径。
191
+ @dataclass
192
+ class RegressionTestState:
193
+ # 项目配置中的统一测试入口(当前平台实际选中的参数数组)
194
+ entry: list[str] = field(default_factory=list)
195
+ # 实际执行的命令参数数组
196
+ command: list[str] = field(default_factory=list)
197
+ # 项目内工作目录(相对项目根;空表示项目根)
198
+ cwd: str = ""
199
+ # 最终全量入口允许运行的最长秒数
200
+ timeout_seconds: int | None = None
201
+ # 开始和结束时间(ISO 8601 UTC)
202
+ started_at: str | None = None
203
+ finished_at: str | None = None
204
+ duration_seconds: float | None = None
205
+ # not_run / passed / failed / timeout / error / unavailable
206
+ status: str = "not_run"
207
+ # 测试进程退出码;无法启动或超时时为空
208
+ exit_code: int | None = None
209
+ # 执行时对应的完整代码快照哈希
210
+ code_snapshot_hash: str | None = None
211
+ # 机器记录编号:追踪表和整体验收用它引用这次精确执行
212
+ record_id: str | None = None
213
+ # 有界输出事实
214
+ output_tail: str = ""
215
+ output_sha256: str | None = None
216
+ output_bytes: int | None = None
217
+ platform: str = ""
218
+ executable: str = ""
219
+
220
+
221
+ # 穿刺阶段开始时的设计文档基线
222
+ # 门2用它判断穿刺结论要求修改设计时,相关文档是否真的发生了变化
223
+ @dataclass
224
+ class SpikeBaselineState:
225
+ # 记录基线的时间;非空表示已经完成基线记录,即使某个文件当时不存在
226
+ captured_at: str | None = None
227
+ # spec/产品总说明.md 及其功能清单链接文档的整体 SHA256 哈希
228
+ product_design_hash: str | None = None
229
+ # 参与产品设计整体哈希的相对路径,便于状态检查和问题定位
230
+ product_design_paths: list[str] = field(default_factory=list)
231
+ # spec/代码架构设计.md 的 SHA256 哈希
232
+ code_design_hash: str | None = None
233
+ # 旧工作流已经进入 spike,但旧 state.json 没有保存入场基线
234
+ # True 表示无法可靠还原穿刺开始前的设计内容,不能假装当前文件就是旧基线
235
+ legacy_unavailable: bool = False
236
+
237
+
238
+ @dataclass
239
+ class RollbackState:
240
+ """当前 Run 的实施代码回退清单。"""
241
+
242
+ manifest_path: str | None = None
243
+ manifest_hash: str | None = None
244
+ prepared_at: str | None = None
245
+ plan_hash: str | None = None
246
+ code_baseline_hash: str | None = None
247
+ planned_paths: list[str] = field(default_factory=list)
248
+ # 整轮作废的逐项恢复进度:恢复开始、全部项目已恢复、临时副本清理完成。
249
+ # 逐项状态保存在回退清单文件中;部分作废不会被写成整体恢复完成。
250
+ restore_started_at: str | None = None
251
+ # abort 已经恢复代码、但临时副本尚未清理完成时记录时间。
252
+ # 重试 abort 只继续清理,不能再次覆盖用户在恢复后做的新修改。
253
+ restored_at: str | None = None
254
+ cleanup_completed_at: str | None = None
255
+
256
+
257
+ # 整个 workflow Run 的当前快照,对应 state.json 的完整结构
258
+ # 每次 CLI 调用都是新进程,state 必须落盘,下次进程启动时读回来
259
+ @dataclass
260
+ class WorkflowState:
261
+ # 启动时生成,格式 YYYY-MM-DD-HHmm-<intent>,用于 journal 追溯和文件名
262
+ workflow_id: str
263
+ # 工作意图:from_scratch / product_change / bugfix(替代旧 entry/scenario)
264
+ intent: str
265
+ # Run 生命周期:active(进行中)/ completed(done 收工)/ aborted(abort 作废)
266
+ # 替代用 current_stage=completed 推断 Run 状态的旧模型
267
+ run_status: str = "active"
268
+ # 当前 stage 名(如 "spec" / "impl" / ...);末段 --confirmed 后临时置 "completed",由 done 确认
269
+ current_stage: str = ""
270
+ # 启动时间 ISO 8601 UTC
271
+ started_at: str = ""
272
+ # done 时写结束时间(run_status=completed);abort 时为 None
273
+ ended_at: str | None = None
274
+ # abort 时写作废时间(run_status=aborted);done 时为 None
275
+ aborted_at: str | None = None
276
+ # 旧版单主题字段。只用于读取旧 state.json;新流程使用 topics。
277
+ topic: str | None = None
278
+ # 本次需求的全部验收主题。修 bug 在 reproduce 确认,其他意图在 acceptance_plan 确认。
279
+ topics: list[str] = field(default_factory=list)
280
+ # 从零做清场确认标记:workflow start --intent from_scratch --confirm-clean 时置 true
281
+ clean_confirmed: bool = False
282
+ # spike 跳过标记:gate spike --skip 时置 true
283
+ spike_skipped: bool = False
284
+ # PathComposer 在 start 时解析出的完整 stage 名顺序,固定不再变
285
+ # 后续命令(discuss/gate)读这个列表找当前 stage 对应的策略类
286
+ stage_path: list[str] = field(default_factory=list)
287
+ # 每个 stage 的细粒度状态,key 是 stage 名
288
+ stages: dict[str, StageState] = field(default_factory=dict)
289
+ # 架构门禁标记(初步/详细),见 ArchitectureState
290
+ architecture: ArchitectureState = field(default_factory=ArchitectureState)
291
+ # 验证绑定哈希,见 VerificationState
292
+ verification: VerificationState = field(default_factory=VerificationState)
293
+ # 最终全量回归状态,见 RegressionTestState
294
+ regression_test: RegressionTestState = field(default_factory=RegressionTestState)
295
+ # 穿刺进入时的产品设计和代码设计基线
296
+ spike_baseline: SpikeBaselineState = field(default_factory=SpikeBaselineState)
297
+ # 实施前保存的真实文件内容;只用于整个 Run 中止时恢复代码
298
+ rollback: RollbackState = field(default_factory=RollbackState)
299
+ # 上游失效或用户主动退回后的恢复说明;用于 status 和“下一步”解释当前阶段
300
+ recovery: RecoveryContext = field(default_factory=RecoveryContext)
301
+ # 自由扩展口子(hooks 等后面用,第一版为空)
302
+ meta: dict = field(default_factory=dict)
303
+
304
+
305
+ # 生成 ISO 8601 UTC 时间戳,microsecond=0 让时间戳更干净
306
+ # 供 state 的 started_at/ended_at/aborted_at 和 journal 的 ts 字段复用
307
+ def now_iso() -> str:
308
+ # datetime.now(timezone.utc) 拿到 UTC 时间
309
+ # .replace(microsecond=0) 去掉微秒
310
+ # .isoformat() 转 ISO 8601 字符串
311
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
312
+
313
+
314
+ # 把 WorkflowState dataclass 转成可 JSON 序列化的 dict
315
+ # asdict 会递归把 dataclass 转成 dict,包括嵌套的 StageState 和 GateState
316
+ def state_to_dict(state: WorkflowState) -> dict:
317
+ return asdict(state)
318
+
319
+
320
+ def _test_execution_record_from_dict(data: dict | None) -> TestExecutionRecord | None:
321
+ if not isinstance(data, dict):
322
+ return None
323
+ command = data.get("command", [])
324
+ if isinstance(command, str):
325
+ command = [command] if command else []
326
+ return TestExecutionRecord(
327
+ test_entries=data.get("test_entries", []),
328
+ command=command,
329
+ cwd=data.get("cwd", ""),
330
+ timeout_seconds=data.get("timeout_seconds"),
331
+ started_at=data.get("started_at"),
332
+ finished_at=data.get("finished_at"),
333
+ duration_seconds=data.get("duration_seconds"),
334
+ exit_code=data.get("exit_code"),
335
+ status=data.get("status", "passed"),
336
+ environment=data.get("environment", {}),
337
+ code_snapshot_hash=data.get("code_snapshot_hash"),
338
+ test_code_hash=data.get("test_code_hash"),
339
+ record_id=data.get("record_id"),
340
+ output_tail=data.get("output_tail", ""),
341
+ output_sha256=data.get("output_sha256"),
342
+ output_bytes=data.get("output_bytes"),
343
+ platform=data.get("platform", ""),
344
+ executable=data.get("executable", ""),
345
+ )
346
+
347
+
348
+ def _test_task_from_dict(data: dict) -> TestTaskState:
349
+ return TestTaskState(
350
+ test_entries=data.get("test_entries", []),
351
+ command=data.get("command", []),
352
+ cwd=data.get("cwd", ""),
353
+ dependencies=data.get("dependencies", []),
354
+ timeout_seconds=data.get("timeout_seconds", 600),
355
+ status=data.get("status", "pending"),
356
+ prepared_at=data.get("prepared_at"),
357
+ last_error=data.get("last_error"),
358
+ current_record=_test_execution_record_from_dict(data.get("current_record")),
359
+ )
360
+
361
+
362
+ def _acceptance_record_from_dict(data: dict) -> AcceptanceCriterionRecord:
363
+ return AcceptanceCriterionRecord(
364
+ topic=data.get("topic", ""),
365
+ criterion_id=data.get("criterion_id", ""),
366
+ method=data.get("method", ""),
367
+ result=data.get("result", "passed"),
368
+ actual_result=data.get("actual_result", ""),
369
+ user_answer=data.get("user_answer"),
370
+ evidence=data.get("evidence", ""),
371
+ confirmed_at=data.get("confirmed_at"),
372
+ acceptance_plan_hash=data.get("acceptance_plan_hash"),
373
+ impl_hash=data.get("impl_hash"),
374
+ test_result_hash=data.get("test_result_hash"),
375
+ test_ids=data.get("test_ids", []),
376
+ test_record_ids=data.get("test_record_ids", []),
377
+ record_id=data.get("record_id"),
378
+ )
379
+
380
+
381
+ def _regression_state_from_dict(data: dict) -> RegressionTestState:
382
+ """兼容旧状态:entry/command 由字符串转为参数数组,新机器字段缺省。"""
383
+ entry = data.get("entry")
384
+ if isinstance(entry, str):
385
+ entry = [entry] if entry else []
386
+ command = data.get("command")
387
+ if isinstance(command, str):
388
+ command = [command] if command else []
389
+ return RegressionTestState(
390
+ entry=entry or [],
391
+ command=command or [],
392
+ cwd=data.get("cwd", ""),
393
+ timeout_seconds=data.get("timeout_seconds"),
394
+ started_at=data.get("started_at"),
395
+ finished_at=data.get("finished_at"),
396
+ duration_seconds=data.get("duration_seconds"),
397
+ status=data.get("status", "not_run"),
398
+ exit_code=data.get("exit_code"),
399
+ code_snapshot_hash=data.get("code_snapshot_hash"),
400
+ record_id=data.get("record_id"),
401
+ output_tail=data.get("output_tail", ""),
402
+ output_sha256=data.get("output_sha256"),
403
+ output_bytes=data.get("output_bytes"),
404
+ platform=data.get("platform", ""),
405
+ executable=data.get("executable", ""),
406
+ )
407
+
408
+
409
+ # 从 dict 反序列化成 WorkflowState dataclass
410
+ # 手动重建嵌套的 StageState 和 GateState,因为 dataclass 不自动处理嵌套 dict→dataclass
411
+ def state_from_dict(data: dict) -> WorkflowState:
412
+ # 先把 stages dict 里的每个 stage 从裸 dict 重建为 StageState dataclass
413
+ stages = {}
414
+ # 遍历 state.json 里的 stages 字典,逐个重建
415
+ for stage_name, stage_data in data.get("stages", {}).items():
416
+ # 从 stage_data 重建 GateState(3 道闸)
417
+ gate_data = stage_data.get("gate", {})
418
+ gate = GateState(
419
+ discussion_complete=gate_data.get("discussion_complete", False),
420
+ code_validated=gate_data.get("code_validated", False),
421
+ user_confirmed=gate_data.get("user_confirmed", False),
422
+ )
423
+ # 从 stage_data 重建 StageState
424
+ stages[stage_name] = StageState(
425
+ status=stage_data.get("status", "pending"),
426
+ artifact_paths=stage_data.get("artifact_paths", []),
427
+ artifact_produced_at=stage_data.get("artifact_produced_at"),
428
+ artifact_baseline_captured_at=stage_data.get("artifact_baseline_captured_at"),
429
+ artifact_baseline_hashes=stage_data.get("artifact_baseline_hashes", {}),
430
+ code_baseline_hash=stage_data.get("code_baseline_hash"),
431
+ test_code_baseline_hash=stage_data.get("test_code_baseline_hash"),
432
+ non_test_code_baseline_hash=stage_data.get("non_test_code_baseline_hash"),
433
+ existing_code_accepted_hash=stage_data.get("existing_code_accepted_hash"),
434
+ existing_test_code_accepted_hash=stage_data.get("existing_test_code_accepted_hash"),
435
+ discussion_material_hash=stage_data.get("discussion_material_hash"),
436
+ plan_confirmed_hash=stage_data.get("plan_confirmed_hash"),
437
+ test_tasks={
438
+ topic: {
439
+ test_id: _test_task_from_dict(task_data)
440
+ for test_id, task_data in topic_tasks.items()
441
+ }
442
+ for topic, topic_tasks in stage_data.get("test_tasks", {}).items()
443
+ },
444
+ acceptance_records={
445
+ topic: {
446
+ criterion_id: _acceptance_record_from_dict(record_data)
447
+ for criterion_id, record_data in topic_records.items()
448
+ }
449
+ for topic, topic_records in stage_data.get("acceptance_records", {}).items()
450
+ },
451
+ gate=gate,
452
+ )
453
+ # 读 architecture 字段(架构门禁标记)
454
+ arch_data = data.get("architecture", {})
455
+ # 读 verification 字段(验证绑定哈希)
456
+ verification_data = data.get("verification", {})
457
+ # 旧状态中的修改前全量测试基线(test_baseline)已经删除业务用途:
458
+ # 读取时直接忽略,不再写回,本轮旧过渡执行记录不能影响实施。
459
+ # 读 regression_test 字段;旧 state.json 没有时按未执行处理
460
+ regression_test_data = data.get("regression_test", {})
461
+ # 读 spike_baseline 字段;旧 state.json 没有时按未记录处理
462
+ spike_baseline_data = data.get("spike_baseline", {})
463
+ # 读 rollback 字段;旧 state.json 没有时表示尚未准备代码回退基线
464
+ rollback_data = data.get("rollback", {})
465
+ # 读 recovery 字段;旧 state.json 没有时表示当前不是失效恢复流程
466
+ recovery_data = data.get("recovery", {})
467
+ # 兼容旧版单主题 state.json:没有 topics 时把 topic 转成单元素列表。
468
+ legacy_topic = data.get("topic")
469
+ topics = data.get("topics", [])
470
+ if not topics and legacy_topic:
471
+ topics = [legacy_topic]
472
+
473
+ # 重建最外层的 WorkflowState
474
+ return WorkflowState(
475
+ workflow_id=data["workflow_id"],
476
+ intent=data["intent"],
477
+ run_status=data.get("run_status", "active"),
478
+ current_stage=data.get("current_stage", ""),
479
+ started_at=data.get("started_at", ""),
480
+ ended_at=data.get("ended_at"),
481
+ aborted_at=data.get("aborted_at"),
482
+ topic=legacy_topic,
483
+ topics=topics,
484
+ clean_confirmed=data.get("clean_confirmed", False),
485
+ spike_skipped=data.get("spike_skipped", False),
486
+ stage_path=data.get("stage_path", []),
487
+ stages=stages,
488
+ architecture=ArchitectureState(
489
+ preliminary_done=arch_data.get("preliminary_done", False),
490
+ detailed_done=arch_data.get("detailed_done", False),
491
+ ),
492
+ verification=VerificationState(
493
+ impl_hash=verification_data.get("impl_hash"),
494
+ test_plan_hash=verification_data.get("test_plan_hash"),
495
+ acceptance_plan_hash=verification_data.get("acceptance_plan_hash"),
496
+ test_code_hash=verification_data.get("test_code_hash"),
497
+ test_result_hash=verification_data.get("test_result_hash"),
498
+ acceptance_result_hash=verification_data.get("acceptance_result_hash"),
499
+ regression_test_result_hash=verification_data.get("regression_test_result_hash"),
500
+ ),
501
+ regression_test=_regression_state_from_dict(regression_test_data),
502
+ spike_baseline=SpikeBaselineState(
503
+ captured_at=spike_baseline_data.get("captured_at"),
504
+ product_design_hash=spike_baseline_data.get("product_design_hash"),
505
+ product_design_paths=spike_baseline_data.get("product_design_paths", []),
506
+ code_design_hash=spike_baseline_data.get("code_design_hash"),
507
+ legacy_unavailable=spike_baseline_data.get("legacy_unavailable", False),
508
+ ),
509
+ rollback=RollbackState(
510
+ manifest_path=rollback_data.get("manifest_path"),
511
+ manifest_hash=rollback_data.get("manifest_hash"),
512
+ prepared_at=rollback_data.get("prepared_at"),
513
+ plan_hash=rollback_data.get("plan_hash"),
514
+ code_baseline_hash=rollback_data.get("code_baseline_hash"),
515
+ planned_paths=rollback_data.get("planned_paths", []),
516
+ restore_started_at=rollback_data.get("restore_started_at"),
517
+ restored_at=rollback_data.get("restored_at"),
518
+ cleanup_completed_at=rollback_data.get("cleanup_completed_at"),
519
+ ),
520
+ recovery=RecoveryContext(
521
+ source_stage=recovery_data.get("source_stage"),
522
+ return_target=recovery_data.get("return_target"),
523
+ reason=recovery_data.get("reason"),
524
+ affected_stages=recovery_data.get("affected_stages", []),
525
+ affected_topics=recovery_data.get("affected_topics", []),
526
+ created_at=recovery_data.get("created_at"),
527
+ ),
528
+ meta=data.get("meta", {}),
529
+ )
530
+
531
+
532
+ # 从被管理项目的 .workflow_loop/state.json 读取 state
533
+ # 如果文件不存在(还没 start 过),返回 None 让调用方处理
534
+ # project_root 是被管理项目的根目录路径
535
+ def load_state(project_root: str) -> WorkflowState | None:
536
+ # 拼出 state.json 的完整路径(项目根 + .workflow_loop/state.json)
537
+ path = os.path.join(project_root, STATE_FILE)
538
+ # 文件不存在说明还没 start,返回 None
539
+ if not os.path.exists(path):
540
+ return None
541
+ # 读文件、解析 JSON
542
+ with open(path, "r", encoding="utf-8") as f:
543
+ data = json.load(f)
544
+ # 反序列化成 WorkflowState
545
+ return state_from_dict(data)
546
+
547
+
548
+ # 把 WorkflowState 写到被管理项目的 .workflow_loop/state.json
549
+ # ensure_ascii=False 让中文不被转义成 \uXXXX;indent=2 让文件可读
550
+ def save_state(project_root: str, state: WorkflowState) -> None:
551
+ # 拼出 state.json 的完整路径
552
+ path = os.path.join(project_root, STATE_FILE)
553
+ # 确保目录存在(第一次写时 .workflow_loop/ 可能还没建)
554
+ os.makedirs(os.path.dirname(path), exist_ok=True)
555
+ # 序列化 dataclass → dict
556
+ data = state_to_dict(state)
557
+ # 临时文件必须和 state.json 在同一目录,os.replace 才能原子替换。
558
+ fd, temp_path = tempfile.mkstemp(
559
+ prefix=".state.",
560
+ suffix=".tmp",
561
+ dir=os.path.dirname(path),
562
+ text=True,
563
+ )
564
+ try:
565
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
566
+ json.dump(data, f, ensure_ascii=False, indent=2)
567
+ f.flush()
568
+ os.fsync(f.fileno())
569
+ os.replace(temp_path, path)
570
+ finally:
571
+ if os.path.exists(temp_path):
572
+ os.unlink(temp_path)
573
+
574
+
575
+ # 判断当前项目是否有进行中的 Run(Active Run Guard 用)
576
+ # state.json 存在且 run_status=active → True(禁止再 start)
577
+ # state.json 不存在 / completed / aborted → False(允许新 start)
578
+ def is_active_run(project_root: str) -> bool:
579
+ # 读 state
580
+ state = load_state(project_root)
581
+ # state 存在且 run_status 是 active 才返回 True
582
+ return state is not None and state.run_status == "active"