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
@@ -0,0 +1,78 @@
1
+ import json
2
+ import os
3
+ from datetime import datetime, timezone
4
+
5
+ # journal.jsonl 的相对路径(相对于项目根)
6
+ # 放在 .workflow_loop/ 下,和 state.json、project.json 同级
7
+ JOURNAL_FILE = os.path.join(".workflow_loop", "journal.jsonl")
8
+
9
+
10
+ # 往 journal.jsonl 追加一条记录
11
+ # journal 是 append-only 的历史记录("发生过啥"),不可改
12
+ # 和 state.json("现在在哪",可重写)分离:崩溃恢复时可以从 journal 重建 state
13
+ def append_entry(project_root: str, action: str, actor: str, **kwargs) -> None:
14
+ # 拼出 journal.jsonl 的完整路径
15
+ path = os.path.join(project_root, JOURNAL_FILE)
16
+ # 确保目录存在(第一次写时 .workflow_loop/ 可能还没建)
17
+ os.makedirs(os.path.dirname(path), exist_ok=True)
18
+ # 组装一条 journal 记录:时间戳 + 动作类型 + 执行者 + 额外字段
19
+ entry = {
20
+ # ISO 8601 UTC 时间戳,去掉微秒让格式更干净
21
+ "ts": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
22
+ # 动作类型(中文受控词表:工作流启动/提示词加载/门禁讨论完毕/...)
23
+ "action": action,
24
+ # 执行者:ai / user / workflow.py(谁触发了这个动作)
25
+ "actor": actor,
26
+ # 额外字段(如 stage=xxx, passed=true 等,按 action 类型不同带不同 payload)
27
+ **kwargs,
28
+ }
29
+ # 追加写一行 JSON(不覆盖已有内容),ensure_ascii=False 保留中文
30
+ with open(path, "a", encoding="utf-8") as f:
31
+ # 每条记录一行,jsonl 格式(JSON Lines)
32
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
33
+
34
+
35
+ # 读最近 N 条 journal 记录(status 命令用,默认 10 条)
36
+ # 返回 list[dict],每条是一个 JSON 对象
37
+ def read_recent(project_root: str, count: int = 10) -> list[dict]:
38
+ # 拼出 journal.jsonl 的完整路径
39
+ path = os.path.join(project_root, JOURNAL_FILE)
40
+ # 文件不存在说明还没 start 过,返回空列表
41
+ if not os.path.exists(path):
42
+ return []
43
+ # 收集所有记录
44
+ entries = []
45
+ # 逐行读取 jsonl 文件
46
+ with open(path, "r", encoding="utf-8") as f:
47
+ for line in f:
48
+ # 去掉行首行尾空白
49
+ line = line.strip()
50
+ # 跳过空行
51
+ if line:
52
+ # 解析 JSON 行
53
+ entries.append(json.loads(line))
54
+ # 只返回最后 count 条(最近的记录)
55
+ return entries[-count:]
56
+
57
+
58
+ # 读所有 journal 记录(调试和审计用)
59
+ # 返回 list[dict],按时间顺序(从早到晚)
60
+ def read_all(project_root: str) -> list[dict]:
61
+ # 拼出 journal.jsonl 的完整路径
62
+ path = os.path.join(project_root, JOURNAL_FILE)
63
+ # 文件不存在说明还没 start 过,返回空列表
64
+ if not os.path.exists(path):
65
+ return []
66
+ # 收集所有记录
67
+ entries = []
68
+ # 逐行读取 jsonl 文件
69
+ with open(path, "r", encoding="utf-8") as f:
70
+ for line in f:
71
+ # 去掉行首行尾空白
72
+ line = line.strip()
73
+ # 跳过空行
74
+ if line:
75
+ # 解析 JSON 行
76
+ entries.append(json.loads(line))
77
+ # 返回所有记录(从早到晚)
78
+ return entries
@@ -0,0 +1,152 @@
1
+ from .project import is_project_design_initialized
2
+ from .stages import (
3
+ SpecStage,
4
+ CodeDesignStage,
5
+ SpikeStage,
6
+ ImplStage,
7
+ AcceptancePlanStage,
8
+ TestPlanStage,
9
+ TestCodeStage,
10
+ TestExecutionStage,
11
+ TopicAcceptanceStage,
12
+ RegressionTestStage,
13
+ OverallAcceptanceStage,
14
+ UpdateCodeDesignStage,
15
+ ProjectDesignInitStage,
16
+ ReviseCodeDesignStage,
17
+ ReproduceStage,
18
+ )
19
+ from .stages.base import StageStrategy
20
+
21
+ # from_scratch(从零做)的完整 stage 路径
22
+ # 顺序固定:先产品设计与功能拆分、后初步架构(先定做什么,再定怎么搭)
23
+ # 然后验证技术不确定性 → 验收计划 → 测试计划 → 实施/记录 → 写测试代码 → 执行测试 → 主题验收
24
+ # → 最终全量回归 → 整体验收 → 最终设计同步
25
+ # 共享后半截:acceptance_plan → test_plan → impl → test_code → test_execution
26
+ # → topic_acceptance → regression_test → overall_acceptance → update_code_design
27
+ FROM_SCRATCH_PATH = [
28
+ # 产品设计阶段:产出 spec/product.md + spec/feature_*.md
29
+ SpecStage,
30
+ # 初步架构阶段:产出 spec/architecture_code_design.md(从零做的初步架构)
31
+ CodeDesignStage,
32
+ # 穿刺阶段:验证真实场景中的技术不确定性,写清单和每项结论;临时代码按需进入 spike_tmp/
33
+ # 可选 stage,可通过 gate spike --skip 跳过
34
+ SpikeStage,
35
+ # 验收计划阶段:制定什么算完成,产出 traceability.md + acceptance/index.md + acceptance/<topic>_plan.md
36
+ AcceptancePlanStage,
37
+ # 测试计划阶段:把验收条件转为可执行测试范围,产出 qa/<topic>_plan.md
38
+ TestPlanStage,
39
+ # 实施阶段:先确认全部主题计划,再修改真实代码并记录实施结果
40
+ ImplStage,
41
+ # 按验收计划编写测试代码;本阶段不执行测试、不产出测试结果
42
+ TestCodeStage,
43
+ # 执行测试代码并记录主题测试结果
44
+ TestExecutionStage,
45
+ # 测试通过后,按主题验收计划核对用户结果
46
+ TopicAcceptanceStage,
47
+ # 全部主题完成后,对合并代码执行最终全量回归
48
+ RegressionTestStage,
49
+ # 最终全量回归通过后,确认整个需求是否完成
50
+ OverallAcceptanceStage,
51
+ # 最终设计同步:更新 spec/architecture_code_design.md 反映产品、架构和真实代码映射
52
+ # 所有意图末环同名,强制不可跳过
53
+ UpdateCodeDesignStage,
54
+ ]
55
+
56
+ # product_change(改产品)的基础 stage 路径(不含 project_design_init 前置)
57
+ # 和 from_scratch 的差异:用 ReviseCodeDesignStage 替代 CodeDesignStage
58
+ # spec 之后必须经过 revise_code_design(设计期改架构),不能只改产品不改架构
59
+ PRODUCT_CHANGE_BASE = [
60
+ # 产品设计阶段:基于现状重新设计,可新增/修改/删除功能文档
61
+ SpecStage,
62
+ # 设计期架构修订:按变更后的产品设计改架构图
63
+ # 与末段 update_code_design(详细落地)名称分离,避免同一 Run 内 stage 名冲突
64
+ ReviseCodeDesignStage,
65
+ # 穿刺阶段(可选)
66
+ SpikeStage,
67
+ # 验收计划阶段
68
+ AcceptancePlanStage,
69
+ # 测试计划阶段
70
+ TestPlanStage,
71
+ # 实施阶段:先确认全部主题计划,再修改真实代码并记录实施结果
72
+ ImplStage,
73
+ # 先编写测试代码,再执行测试,再按主题验收
74
+ TestCodeStage,
75
+ TestExecutionStage,
76
+ TopicAcceptanceStage,
77
+ # 最终全量回归
78
+ RegressionTestStage,
79
+ # 整体验收
80
+ OverallAcceptanceStage,
81
+ # 最终设计同步(强制)
82
+ UpdateCodeDesignStage,
83
+ ]
84
+
85
+ # bugfix(修 bug)的基础 stage 路径(不含 project_design_init 前置)
86
+ # 和 from_scratch 的差异:没有 spec/code_design,先 reproduce,再经过可选 spike;
87
+ # 在共享后半截中使用 impl 制定并执行修复实施计划
88
+ # 末段 update_code_design 即使无架构变化也必须走,在门禁中显式确认"架构未变化"
89
+ BUGFIX_BASE = [
90
+ # 复现阶段:复现 bug + 根因分析,并确定一份缺陷记录对应的验收主题
91
+ ReproduceStage,
92
+ # 穿刺阶段:验证修复仍依赖的真实技术不确定性;没有时由用户确认跳过
93
+ SpikeStage,
94
+ # 验收计划阶段
95
+ AcceptancePlanStage,
96
+ # 测试计划阶段
97
+ TestPlanStage,
98
+ # 实施阶段:先确认全部主题计划,再修改真实代码并记录实施结果
99
+ ImplStage,
100
+ # 先编写测试代码,再执行测试,再按主题验收
101
+ TestCodeStage,
102
+ TestExecutionStage,
103
+ TopicAcceptanceStage,
104
+ # 最终全量回归
105
+ RegressionTestStage,
106
+ # 整体验收
107
+ OverallAcceptanceStage,
108
+ # 最终设计同步(强制,无架构变化也要显式确认)
109
+ UpdateCodeDesignStage,
110
+ ]
111
+
112
+ # 正式互斥意图列表(start --intent 的合法值)
113
+ # docs_only 暂不作为正式意图
114
+ INTENT_CHOICES = ["from_scratch", "product_change", "bugfix"]
115
+
116
+
117
+ # 根据 intent 和项目事实返回 stage 列表(CONTEXT.md "Path Composer")
118
+ # 取代旧的四个 Scenario 类并行流水线与 SCENARIO_REGISTRY
119
+ # 在 start --intent 时调一次,结果存入 state.stage_path,后续命令读 state.stage_path
120
+ # 不在每次命令调用时重新跑 PathComposer(条件在 start 时就固定)
121
+ def build_stage_path(intent: str, project_root: str) -> list[StageStrategy]:
122
+ # 从零做:直接返回 FROM_SCRATCH_PATH 的实例列表
123
+ # from_scratch 不走 project_design_init(那只有 product_change/bugfix 走)
124
+ # from_scratch 在 spec + code_design 都 --confirmed 后写 project_design_initialized=true
125
+ if intent == "from_scratch":
126
+ # 实例化每个 stage 类
127
+ return [cls() for cls in FROM_SCRATCH_PATH]
128
+ # 改产品:先检查 project_design_initialized
129
+ if intent == "product_change":
130
+ # 收集 stage 实例
131
+ stages = []
132
+ # 如果项目设计未初始化,前置 project_design_init stage
133
+ # 不能用 architecture_code_design.md 是否存在决定跳过
134
+ if not is_project_design_initialized(project_root):
135
+ # 前置项目设计架构初始化 stage
136
+ stages.append(ProjectDesignInitStage())
137
+ # 追加 product_change 的基础路径
138
+ stages.extend(cls() for cls in PRODUCT_CHANGE_BASE)
139
+ return stages
140
+ # 修 bug:先检查 project_design_initialized
141
+ if intent == "bugfix":
142
+ # 收集 stage 实例
143
+ stages = []
144
+ # 如果项目设计未初始化,前置 project_design_init stage
145
+ if not is_project_design_initialized(project_root):
146
+ # 前置项目设计架构初始化 stage
147
+ stages.append(ProjectDesignInitStage())
148
+ # 追加 bugfix 的基础路径
149
+ stages.extend(cls() for cls in BUGFIX_BASE)
150
+ return stages
151
+ # 未知 intent:报错并提示合法值
152
+ raise ValueError(f"未知 intent: {intent},可选值: {INTENT_CHOICES}")
@@ -0,0 +1,176 @@
1
+ """跨平台受控进程执行器。
2
+
3
+ 主题测试和最终全量回归共用同一套执行行为:只接受命令参数数组和项目内工作目录,
4
+ 不经过 Shell;POSIX 新建会话并按进程组先终止后强制结束;Windows 新建进程组并
5
+ 使用系统进程树终止能力;所有路径都等待进程完成并收集有界输出。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import shutil
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+
20
+ # 输出摘要保留的末尾字节数;完整输出只保存哈希和字节数,不落盘
21
+ OUTPUT_TAIL_BYTES = 8 * 1024
22
+ # 超时后先礼貌终止,再等这些秒数才强制结束
23
+ TERMINATE_GRACE_SECONDS = 5
24
+
25
+
26
+ @dataclass
27
+ class ProcessRequest:
28
+ """一次受控执行请求。"""
29
+
30
+ argv: list[str]
31
+ # 项目内工作目录(绝对路径;由调用方校验在项目内)
32
+ cwd: str
33
+ timeout_seconds: int
34
+ # 额外环境变量(在当前环境基础上覆盖);None 表示原样继承
35
+ extra_env: dict[str, str] | None = None
36
+
37
+
38
+ @dataclass
39
+ class ProcessResult:
40
+ """一次受控执行的机器事实。"""
41
+
42
+ status: str # passed / failed / timeout / error
43
+ exit_code: int | None
44
+ started_at: str
45
+ finished_at: str
46
+ duration_seconds: float
47
+ output_tail: str
48
+ output_sha256: str
49
+ output_bytes: int
50
+ platform: str
51
+ # argv[0] 解析出的实际可执行文件;无法解析时保留原样
52
+ executable: str
53
+ error_message: str = ""
54
+ argv: list[str] = field(default_factory=list)
55
+ cwd: str = ""
56
+
57
+
58
+ def _now_iso() -> str:
59
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
60
+
61
+
62
+ def resolve_executable(command: str) -> str:
63
+ """返回当前环境实际会执行的程序路径;无法解析时保留原参数。"""
64
+ return shutil.which(command) or command
65
+
66
+
67
+ def _kill_process_tree(process: subprocess.Popen) -> None:
68
+ """按平台清理整个进程组或进程树,不遗留测试子进程。"""
69
+ if sys.platform.startswith("win"):
70
+ # Windows:使用系统进程树终止能力(taskkill /T /F)
71
+ subprocess.run(
72
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
73
+ capture_output=True,
74
+ check=False,
75
+ )
76
+ return
77
+ # POSIX:进程在新会话中启动,按进程组先 SIGTERM,宽限后 SIGKILL
78
+ try:
79
+ pgid = os.getpgid(process.pid)
80
+ except ProcessLookupError:
81
+ return
82
+ try:
83
+ os.killpg(pgid, signal.SIGTERM)
84
+ except ProcessLookupError:
85
+ return
86
+ deadline = time.monotonic() + TERMINATE_GRACE_SECONDS
87
+ while time.monotonic() < deadline:
88
+ if process.poll() is not None:
89
+ break
90
+ time.sleep(0.1)
91
+ try:
92
+ os.killpg(pgid, signal.SIGKILL)
93
+ except ProcessLookupError:
94
+ pass
95
+
96
+
97
+ def run_process(request: ProcessRequest) -> ProcessResult:
98
+ """执行一次命令并返回完整机器事实;超时后清理整个进程组或进程树。"""
99
+ started_at = _now_iso()
100
+ start_clock = time.monotonic()
101
+ platform_name = sys.platform
102
+ executable = resolve_executable(request.argv[0])
103
+
104
+ env = None
105
+ if request.extra_env:
106
+ env = dict(os.environ)
107
+ env.update(request.extra_env)
108
+
109
+ popen_kwargs: dict = {
110
+ "args": request.argv,
111
+ "cwd": request.cwd,
112
+ "stdout": subprocess.PIPE,
113
+ "stderr": subprocess.STDOUT,
114
+ "shell": False,
115
+ "env": env,
116
+ }
117
+ if sys.platform.startswith("win"):
118
+ # 新建进程组,供 taskkill /T 清理整棵进程树
119
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
120
+ else:
121
+ # 新建会话(同时成为新进程组组长),供 killpg 清理
122
+ popen_kwargs["start_new_session"] = True
123
+
124
+ def _finish(
125
+ status: str,
126
+ exit_code: int | None,
127
+ output: bytes,
128
+ error_message: str = "",
129
+ ) -> ProcessResult:
130
+ finished_at = _now_iso()
131
+ tail = output[-OUTPUT_TAIL_BYTES:].decode("utf-8", errors="replace")
132
+ return ProcessResult(
133
+ status=status,
134
+ exit_code=exit_code,
135
+ started_at=started_at,
136
+ finished_at=finished_at,
137
+ duration_seconds=round(time.monotonic() - start_clock, 3),
138
+ output_tail=tail,
139
+ output_sha256=hashlib.sha256(output).hexdigest(),
140
+ output_bytes=len(output),
141
+ platform=platform_name,
142
+ executable=executable,
143
+ error_message=error_message,
144
+ argv=list(request.argv),
145
+ cwd=request.cwd,
146
+ )
147
+
148
+ try:
149
+ process = subprocess.Popen(**popen_kwargs)
150
+ except (OSError, ValueError) as exc:
151
+ return _finish("error", None, b"", f"无法启动命令: {exc}")
152
+
153
+ try:
154
+ output, _ = process.communicate(timeout=request.timeout_seconds)
155
+ except subprocess.TimeoutExpired as first_timeout:
156
+ _kill_process_tree(process)
157
+ # communicate() 超时后的再次调用会返回从进程启动起的完整累计输出,
158
+ # 不能再与第一次异常中的部分输出拼接,否则会重复计算摘要、哈希和字节数。
159
+ try:
160
+ output, _ = process.communicate(timeout=TERMINATE_GRACE_SECONDS + 5)
161
+ except subprocess.TimeoutExpired as final_timeout:
162
+ output = final_timeout.output
163
+ if output is None:
164
+ output = first_timeout.output
165
+ if isinstance(output, str):
166
+ output = output.encode("utf-8", errors="replace")
167
+ return _finish(
168
+ "timeout",
169
+ None,
170
+ output or b"",
171
+ f"超过 {request.timeout_seconds} 秒未完成,已清理整个进程组或进程树",
172
+ )
173
+
174
+ exit_code = process.returncode
175
+ status = "passed" if exit_code == 0 else "failed"
176
+ return _finish(status, exit_code, output or b"")