ai-dev-flow 3.10.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 (35) hide show
  1. ai_dev_flow/__init__.py +5 -0
  2. ai_dev_flow/__main__.py +6 -0
  3. ai_dev_flow/checkpoint.py +65 -0
  4. ai_dev_flow/cli.py +598 -0
  5. ai_dev_flow/config.py +404 -0
  6. ai_dev_flow/data/pipeline.yaml +193 -0
  7. ai_dev_flow/data/profiles/java-spring.yml +140 -0
  8. ai_dev_flow/data/recipes/steps/00-init-project.yaml +168 -0
  9. ai_dev_flow/data/recipes/steps/01-refine-requirement.yaml +117 -0
  10. ai_dev_flow/data/recipes/steps/02-analysis.yaml +97 -0
  11. ai_dev_flow/data/recipes/steps/03-design.yaml +91 -0
  12. ai_dev_flow/data/recipes/steps/03.5-decompose.yaml +120 -0
  13. ai_dev_flow/data/recipes/steps/05-build.yaml +72 -0
  14. ai_dev_flow/data/recipes/steps/06-review.yaml +115 -0
  15. ai_dev_flow/data/recipes/steps/07-delivery.yaml +76 -0
  16. ai_dev_flow/data/recipes/steps/task-template.yaml +74 -0
  17. ai_dev_flow/error_handler.py +122 -0
  18. ai_dev_flow/executor.py +406 -0
  19. ai_dev_flow/git_ops.py +187 -0
  20. ai_dev_flow/knowledge_accumulator.py +121 -0
  21. ai_dev_flow/logger.py +84 -0
  22. ai_dev_flow/sandbox.py +291 -0
  23. ai_dev_flow/semantic_summarizer.py +351 -0
  24. ai_dev_flow/snapshot.py +210 -0
  25. ai_dev_flow/state.py +87 -0
  26. ai_dev_flow/task_context.py +134 -0
  27. ai_dev_flow/task_graph.py +841 -0
  28. ai_dev_flow/task_state.py +151 -0
  29. ai_dev_flow/watchdog.py +120 -0
  30. ai_dev_flow-3.10.0.dist-info/METADATA +171 -0
  31. ai_dev_flow-3.10.0.dist-info/RECORD +35 -0
  32. ai_dev_flow-3.10.0.dist-info/WHEEL +5 -0
  33. ai_dev_flow-3.10.0.dist-info/entry_points.txt +2 -0
  34. ai_dev_flow-3.10.0.dist-info/licenses/LICENSE +21 -0
  35. ai_dev_flow-3.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,351 @@
1
+ """语义摘要器 — 从文件中提取结构化关键信息,替代纯截断。
2
+
3
+ 策略(零 AI 成本):
4
+ - 代码文件:提取类/函数签名、参数、返回类型、docstring、关键注释
5
+ - 文档文件:提取标题层级 + 首句摘要
6
+ - 配置文件:提取顶层结构
7
+ - 提取结果缓存到 .ai-dev/summaries/{hash}.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import hashlib
14
+ import logging
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger("ai-dev-flow")
18
+
19
+ SUMMARY_DIR = "summaries"
20
+ MAX_SUMMARY_LINES = 120 # 摘要最大行数
21
+
22
+
23
+ class SemanticSummarizer:
24
+ """文件语义结构提取器。不调用 AI,基于语言规则提取关键信息。"""
25
+
26
+ def __init__(self, ai_dev_dir: Path):
27
+ self.cache_dir = ai_dev_dir / SUMMARY_DIR
28
+ self.cache_dir.mkdir(exist_ok=True)
29
+
30
+ def summarize(self, filepath: Path) -> str | None:
31
+ """对文件做语义摘要。已在缓存中则直接返回。"""
32
+ if not filepath.exists():
33
+ return None
34
+
35
+ try:
36
+ content = filepath.read_text(encoding="utf-8", errors="replace")
37
+ except Exception:
38
+ return None
39
+
40
+ cache_key = hashlib.sha256(content.encode()).hexdigest()[:16]
41
+ cache_file = self.cache_dir / f"{cache_key}.md"
42
+ if cache_file.exists():
43
+ return cache_file.read_text(encoding="utf-8")
44
+
45
+ ext = filepath.suffix.lower()
46
+ lines = content.splitlines()
47
+ size_kb = len(content.encode("utf-8")) // 1024
48
+
49
+ if ext in (".py", ".pyw"):
50
+ summary = self._summarize_python(lines, filepath, len(lines), size_kb)
51
+ elif ext in (".java", ".kt", ".scala"):
52
+ summary = self._summarize_java(lines, filepath, len(lines), size_kb)
53
+ elif ext in (".ts", ".tsx", ".js", ".jsx"):
54
+ summary = self._summarize_typescript(lines, filepath, len(lines), size_kb)
55
+ elif ext in (".md", ".mdx"):
56
+ summary = self._summarize_markdown(lines, filepath, len(lines), size_kb)
57
+ elif ext in (".yml", ".yaml"):
58
+ summary = self._summarize_yaml(lines, filepath, len(lines), size_kb)
59
+ elif ext in (".vue", ".svelte"):
60
+ summary = self._summarize_vue(lines, filepath, len(lines), size_kb)
61
+ elif ext in (".xml", ".html", ".htm"):
62
+ summary = self._summarize_xml(lines, filepath, len(lines), size_kb)
63
+ elif ext in (".sql"):
64
+ summary = self._summarize_sql(lines, filepath, len(lines), size_kb)
65
+ else:
66
+ summary = self._summarize_generic(lines, filepath, len(lines), size_kb)
67
+
68
+ cache_file.write_text(summary, encoding="utf-8")
69
+ return summary
70
+
71
+ # ---- Python ----
72
+
73
+ def _summarize_python(self, lines: list[str], filepath: Path,
74
+ total: int, size_kb: int) -> str:
75
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
76
+ imports, classes, funcs, constants, comments = [], [], [], [], []
77
+
78
+ for i, line in enumerate(lines, 1):
79
+ stripped = line.strip()
80
+ if not stripped:
81
+ continue
82
+
83
+ # Imports
84
+ if stripped.startswith(("import ", "from ")):
85
+ imports.append(stripped)
86
+ # Class defs
87
+ elif stripped.startswith("class "):
88
+ doc = self._peek_docstring(lines, i)
89
+ classes.append(f"- `{stripped}`{doc}")
90
+ # Function defs (not methods — those are indented)
91
+ elif stripped.startswith("def ") and not line.startswith((" ", "\t")):
92
+ doc = self._peek_docstring(lines, i)
93
+ funcs.append(f"- `{stripped}`{doc}")
94
+ # Decorators (capture before class/def)
95
+ elif stripped.startswith("@"):
96
+ if i < total and lines[i].strip().startswith(("class ", "def ")):
97
+ continue # will be captured with the class/def
98
+ # Module-level constants
99
+ elif re.match(r'^[A-Z_][A-Z0-9_]*\s*=', stripped):
100
+ constants.append(stripped.split("=")[0].strip())
101
+ # Key comments
102
+ elif re.search(r'(TODO|FIXME|HACK|XXX|NOTE|WARNING)', stripped, re.I):
103
+ comments.append(f" L{i}: {stripped[:100]}")
104
+
105
+ if imports:
106
+ parts.append(f"**Imports**: {', '.join(imports[:15])}")
107
+ if len(imports) > 15:
108
+ parts.append(f" ... and {len(imports) - 15} more")
109
+ if constants:
110
+ parts.append(f"**Constants**: {', '.join(constants[:20])}")
111
+ if classes:
112
+ parts.append(f"\n### Classes ({len(classes)})")
113
+ parts.extend(classes[:30])
114
+ if len(classes) > 30:
115
+ parts.append(f" ... and {len(classes) - 30} more")
116
+ if funcs:
117
+ parts.append(f"\n### Functions ({len(funcs)})")
118
+ parts.extend(funcs[:30])
119
+ if len(funcs) > 30:
120
+ parts.append(f" ... and {len(funcs) - 30} more")
121
+ if comments:
122
+ parts.append("\n### Key Comments")
123
+ parts.extend(comments[:15])
124
+
125
+ return "\n".join(parts)
126
+
127
+ # ---- Java/Kotlin ----
128
+
129
+ def _summarize_java(self, lines: list[str], filepath: Path,
130
+ total: int, size_kb: int) -> str:
131
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
132
+ package, imports, classes, methods, fields, comments = "", [], [], [], [], []
133
+
134
+ for i, line in enumerate(lines, 1):
135
+ stripped = line.strip()
136
+ if not stripped:
137
+ continue
138
+
139
+ if stripped.startswith("package "):
140
+ package = stripped
141
+ elif stripped.startswith("import "):
142
+ imports.append(stripped)
143
+ elif re.match(r'(public |private |protected )?(class |interface |@interface |enum )', stripped):
144
+ classes.append(f"- `{stripped}`")
145
+ elif re.match(r'(public |private |protected )?[\w<>\[\],\s]+\s+\w+\s*\(', stripped):
146
+ methods.append(f"- `{stripped[:120]}`")
147
+ elif re.match(r'(public |private |protected )?static\s+final\s+', stripped):
148
+ fields.append(stripped[:80])
149
+ elif re.search(r'(TODO|FIXME|HACK|XXX)', stripped, re.I):
150
+ comments.append(f" L{i}: {stripped[:100]}")
151
+
152
+ if package:
153
+ parts.append(f"**Package**: {package}")
154
+ if imports:
155
+ parts.append(f"**Imports**: {len(imports)} packages")
156
+ if classes:
157
+ parts.append(f"\n### Classes/Interfaces ({len(classes)})")
158
+ parts.extend(classes)
159
+ if fields:
160
+ parts.append("\n### Constants/Fields")
161
+ parts.extend(fields[:15])
162
+ if methods:
163
+ parts.append(f"\n### Methods ({len(methods)})")
164
+ parts.extend(methods[:40])
165
+ if len(methods) > 40:
166
+ parts.append(f" ... and {len(methods) - 40} more")
167
+ if comments:
168
+ parts.append("\n### Key Comments")
169
+ parts.extend(comments[:15])
170
+
171
+ return "\n".join(parts)
172
+
173
+ # ---- TypeScript/JavaScript ----
174
+
175
+ def _summarize_typescript(self, lines: list[str], filepath: Path,
176
+ total: int, size_kb: int) -> str:
177
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
178
+ imports, exports, classes, funcs, interfaces, comments = [], [], [], [], [], []
179
+
180
+ for i, line in enumerate(lines, 1):
181
+ stripped = line.strip()
182
+ if not stripped:
183
+ continue
184
+
185
+ if stripped.startswith("import "):
186
+ imports.append(stripped[:80])
187
+ elif stripped.startswith("export "):
188
+ exports.append(stripped[:100])
189
+ elif stripped.startswith("class "):
190
+ classes.append(f"- `{stripped[:120]}`")
191
+ elif re.match(r'(export\s+)?(interface|type)\s+', stripped):
192
+ interfaces.append(f"- `{stripped[:120]}`")
193
+ elif re.match(r'(export\s+)?(async\s+)?function\s+', stripped):
194
+ funcs.append(f"- `{stripped[:120]}`")
195
+ elif re.search(r'(TODO|FIXME|HACK|XXX)', stripped, re.I):
196
+ comments.append(f" L{i}: {stripped[:100]}")
197
+
198
+ if imports:
199
+ parts.append(f"**Imports**: {len(imports)} modules")
200
+ if exports:
201
+ parts.append(f"**Exports**: {len(exports)} items")
202
+ if interfaces:
203
+ parts.append(f"\n### Interfaces/Types ({len(interfaces)})")
204
+ parts.extend(interfaces[:20])
205
+ if classes:
206
+ parts.append(f"\n### Classes ({len(classes)})")
207
+ parts.extend(classes[:20])
208
+ if funcs:
209
+ parts.append(f"\n### Functions ({len(funcs)})")
210
+ parts.extend(funcs[:30])
211
+ if comments:
212
+ parts.append("\n### Key Comments")
213
+ parts.extend(comments[:15])
214
+
215
+ return "\n".join(parts)
216
+
217
+ # ---- Markdown ----
218
+
219
+ def _summarize_markdown(self, lines: list[str], filepath: Path,
220
+ total: int, size_kb: int) -> str:
221
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
222
+ headers = []
223
+ for line in lines:
224
+ stripped = line.strip()
225
+ if stripped.startswith("#"):
226
+ headers.append(stripped)
227
+ if headers:
228
+ parts.append("### Document Structure")
229
+ parts.extend(headers[:40])
230
+ if len(headers) > 40:
231
+ parts.append(f" ... and {len(headers) - 40} more headings")
232
+ return "\n".join(parts)
233
+
234
+ # ---- YAML ----
235
+
236
+ def _summarize_yaml(self, lines: list[str], filepath: Path,
237
+ total: int, size_kb: int) -> str:
238
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
239
+ top_keys = []
240
+ for line in lines:
241
+ stripped = line.strip()
242
+ if stripped and not stripped.startswith("#") and not line.startswith(" "):
243
+ top_keys.append(stripped[:100])
244
+ if top_keys:
245
+ parts.append("### Top-level Structure")
246
+ parts.extend(f"- {k}" for k in top_keys[:30])
247
+ return "\n".join(parts)
248
+
249
+ # ---- Vue/Svelte ----
250
+
251
+ def _summarize_vue(self, lines: list[str], filepath: Path,
252
+ total: int, size_kb: int) -> str:
253
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
254
+ sections = []
255
+ in_script, in_style, in_template = False, False, False
256
+ script_lines, style_lines, template_lines = 0, 0, 0
257
+
258
+ for line in lines:
259
+ stripped = line.strip()
260
+ if "<script" in stripped:
261
+ in_script = True
262
+ elif "</script>" in stripped:
263
+ in_script = False
264
+ sections.append(f"- `<script>` ({script_lines} lines)")
265
+ elif "<style" in stripped:
266
+ in_style = True
267
+ elif "</style>" in stripped:
268
+ in_style = False
269
+ sections.append(f"- `<style>` ({style_lines} lines)")
270
+ elif "<template" in stripped:
271
+ in_template = True
272
+ elif "</template>" in stripped:
273
+ in_template = False
274
+ sections.append(f"- `<template>` ({template_lines} lines)")
275
+ elif in_script:
276
+ script_lines += 1
277
+ elif in_style:
278
+ style_lines += 1
279
+ elif in_template:
280
+ template_lines += 1
281
+
282
+ if sections:
283
+ parts.append("### Component Structure")
284
+ parts.extend(sections)
285
+ return "\n".join(parts)
286
+
287
+ # ---- XML/HTML ----
288
+
289
+ def _summarize_xml(self, lines: list[str], filepath: Path,
290
+ total: int, size_kb: int) -> str:
291
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
292
+ tags = set()
293
+ for line in lines:
294
+ for m in re.finditer(r'<(\w+)', line):
295
+ tags.add(m.group(1))
296
+ if tags:
297
+ parts.append(f"**Elements**: {', '.join(sorted(tags)[:30])}")
298
+ return "\n".join(parts)
299
+
300
+ # ---- SQL ----
301
+
302
+ def _summarize_sql(self, lines: list[str], filepath: Path,
303
+ total: int, size_kb: int) -> str:
304
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
305
+ statements = []
306
+ for line in lines:
307
+ stripped = line.strip().upper()
308
+ if re.match(r'(CREATE|ALTER|DROP|INSERT|UPDATE|DELETE|SELECT|GRANT|REVOKE)\b', stripped):
309
+ statements.append(stripped[:120])
310
+ if statements:
311
+ parts.append("### SQL Statements")
312
+ parts.extend(f"- {s}" for s in statements[:20])
313
+ if len(statements) > 20:
314
+ parts.append(f" ... and {len(statements) - 20} more")
315
+ return "\n".join(parts)
316
+
317
+ # ---- Generic fallback ----
318
+
319
+ def _summarize_generic(self, lines: list[str], filepath: Path,
320
+ total: int, size_kb: int) -> str:
321
+ parts = [f"## File: {filepath.name} ({total} lines, {size_kb}KB)\n"]
322
+ parts.append(f"**Type**: {filepath.suffix or 'text'}")
323
+ # Extract lines that look like declarations
324
+ decls = []
325
+ for line in lines[:200]:
326
+ stripped = line.strip()
327
+ if stripped and not stripped.startswith(("#", "//", "/*", "*")):
328
+ if re.match(r'^[\w]+\s+[\w]+\s*[=(]', stripped):
329
+ decls.append(stripped[:120])
330
+ if decls:
331
+ parts.append("### Possible Declarations")
332
+ parts.extend(f"- {d}" for d in decls[:30])
333
+ return "\n".join(parts)
334
+
335
+ # ---- Helpers ----
336
+
337
+ def _peek_docstring(self, lines: list[str], idx: int) -> str:
338
+ """提取紧随定义行的 docstring 首行。"""
339
+ if idx >= len(lines):
340
+ return ""
341
+ next_line = lines[idx].strip()
342
+ # 单行 docstring: """text""" 或 '''text'''
343
+ m = re.match(r'["\']{3}(.+?)["\']{3}', next_line)
344
+ if m:
345
+ return f' — "{m.group(1)[:80]}"'
346
+ # 多行 docstring 开始
347
+ if next_line.startswith(('"""', "'''")):
348
+ text = next_line.lstrip('"').lstrip("'").strip()
349
+ if text:
350
+ return f' — "{text[:80]}"'
351
+ return ""
@@ -0,0 +1,210 @@
1
+ """项目快照 — 文件树 hash + 变更检测,避免每次全量扫描项目。
2
+
3
+ 增量策略:
4
+ - 首次运行:全量扫描,存 mtime+size 到 snapshot.json
5
+ - 后续运行:对比 mtime/size,仅对变化文件重读内容
6
+ - 内容缓存:未变化文件复用上次读取的内容
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import hashlib
13
+ import logging
14
+ from pathlib import Path
15
+ from dataclasses import dataclass
16
+
17
+ logger = logging.getLogger("ai-dev-flow")
18
+
19
+ SNAPSHOT_FILE = "snapshot.json"
20
+
21
+ # 扫描时跳过的目录
22
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
23
+ "target", "build", "dist", ".idea", ".vscode", ".ai-dev"}
24
+
25
+ # 二进制文件扩展名(不做内容缓存)
26
+ BINARY_EXTS = {".jar", ".war", ".class", ".exe", ".dll", ".so", ".dylib",
27
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".gz"}
28
+
29
+
30
+ @dataclass
31
+ class FileEntry:
32
+ path: str # 相对于 project_root 的路径
33
+ size: int
34
+ mtime: float
35
+ hash: str = "" # 仅变更文件计算
36
+
37
+
38
+ @dataclass
39
+ class SnapshotDiff:
40
+ added: list[str]
41
+ removed: list[str]
42
+ modified: list[str]
43
+ unchanged: list[str]
44
+
45
+
46
+ class SnapshotManager:
47
+ """管理项目文件树快照,支持增量变更检测。"""
48
+
49
+ def __init__(self, ai_dev_dir: Path, project_root: Path):
50
+ self.snapshot_path = ai_dev_dir / SNAPSHOT_FILE
51
+ self.project_root = project_root
52
+ self._content_cache: dict[str, str] = {}
53
+
54
+ # ---- 快照构建 ----
55
+
56
+ def build_snapshot(self) -> dict[str, FileEntry]:
57
+ """全量扫描项目,构建文件树快照。"""
58
+ entries: dict[str, FileEntry] = {}
59
+ self._walk(self.project_root, self.project_root, entries)
60
+ logger.info(f"Snapshot built: {len(entries)} files indexed")
61
+ return entries
62
+
63
+ def _walk(self, base: Path, current: Path, entries: dict[str, FileEntry]) -> None:
64
+ try:
65
+ for child in sorted(current.iterdir()):
66
+ if child.name.startswith(".") or child.name in SKIP_DIRS:
67
+ continue
68
+ if child.is_dir():
69
+ self._walk(base, child, entries)
70
+ elif child.is_file():
71
+ try:
72
+ stat = child.stat()
73
+ rel = str(child.relative_to(base)).replace("\\", "/")
74
+ entries[rel] = FileEntry(
75
+ path=rel,
76
+ size=stat.st_size,
77
+ mtime=stat.st_mtime,
78
+ )
79
+ except OSError:
80
+ pass
81
+ except PermissionError:
82
+ pass
83
+
84
+ # ---- 持久化 ----
85
+
86
+ def save(self, entries: dict[str, FileEntry]) -> None:
87
+ data = {
88
+ "project_root": str(self.project_root),
89
+ "files": {
90
+ path: {"size": e.size, "mtime": e.mtime, "hash": e.hash}
91
+ for path, e in entries.items()
92
+ }
93
+ }
94
+ self.snapshot_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
95
+
96
+ def load(self) -> dict[str, FileEntry] | None:
97
+ if not self.snapshot_path.exists():
98
+ return None
99
+ try:
100
+ data = json.loads(self.snapshot_path.read_text(encoding="utf-8"))
101
+ entries = {}
102
+ for path, info in data.get("files", {}).items():
103
+ entries[path] = FileEntry(
104
+ path=path,
105
+ size=info["size"],
106
+ mtime=info["mtime"],
107
+ hash=info.get("hash", ""),
108
+ )
109
+ return entries
110
+ except (json.JSONDecodeError, KeyError) as e:
111
+ logger.warning(f"Snapshot corrupted: {e}")
112
+ return None
113
+
114
+ # ---- 变更检测 ----
115
+
116
+ def diff(self, before: dict[str, FileEntry],
117
+ after: dict[str, FileEntry]) -> SnapshotDiff:
118
+ """对比新旧快照,返回变更分类。"""
119
+ added = [p for p in after if p not in before]
120
+ removed = [p for p in before if p not in after]
121
+ modified = []
122
+ unchanged = []
123
+
124
+ for p in before:
125
+ if p in after:
126
+ bf = before[p]
127
+ af = after[p]
128
+ if bf.mtime != af.mtime or bf.size != af.size:
129
+ modified.append(p)
130
+ else:
131
+ unchanged.append(p)
132
+
133
+ return SnapshotDiff(
134
+ added=added, removed=removed, modified=modified, unchanged=unchanged
135
+ )
136
+
137
+ def get_changed_files(self) -> SnapshotDiff:
138
+ """主 API:对比当前文件系统与上次快照,返回变更文件列表。"""
139
+ old = self.load()
140
+ new = self.build_snapshot()
141
+ if old is None:
142
+ # 首次运行:全部文件视为 added
143
+ return SnapshotDiff(
144
+ added=list(new.keys()),
145
+ removed=[],
146
+ modified=[],
147
+ unchanged=[],
148
+ )
149
+ diff = self.diff(old, new)
150
+ # 对 modified 文件计算内容 hash 以确认真正变更
151
+ real_modified = []
152
+ for p in diff.modified:
153
+ if self._hash_changed(p, old[p], new[p]):
154
+ real_modified.append(p)
155
+ else:
156
+ diff.unchanged.append(p)
157
+ diff.modified = real_modified
158
+ return diff
159
+
160
+ def _hash_changed(self, path: str, old_entry: FileEntry,
161
+ new_entry: FileEntry) -> bool:
162
+ """快速判断文件内容是否真正变更(mtime 可能被 git checkout 刷新)。"""
163
+ # 快速路径:size 不同则必然变更
164
+ if old_entry.size != new_entry.size:
165
+ return True
166
+ # 对 100KB 以内的文本文件做 hash 对比
167
+ if new_entry.size > 100 * 1024:
168
+ return True # 大文件保守视为变更
169
+ ext = Path(path).suffix.lower()
170
+ if ext in BINARY_EXTS:
171
+ return True # 二进制文件保守视为变更
172
+ try:
173
+ fp = self.project_root / path
174
+ content = fp.read_bytes()
175
+ new_hash = hashlib.sha256(content).hexdigest()
176
+ if old_entry.hash:
177
+ return old_entry.hash != new_hash
178
+ # 旧快照无 hash(首次迁移),计算旧 hash
179
+ hashlib.sha256(content).hexdigest()
180
+ new_entry.hash = new_hash
181
+ return True # 无旧 hash 时保守视为变更
182
+ except Exception:
183
+ return True
184
+
185
+ # ---- 内容缓存 ----
186
+
187
+ def read_cached(self, path: str, cache: dict[str, str]) -> str | None:
188
+ """读取文件内容,优先使用缓存。"""
189
+ if path in cache:
190
+ return cache[path]
191
+ fp = self.project_root / path
192
+ if not fp.exists():
193
+ return None
194
+ try:
195
+ content = fp.read_text(encoding="utf-8", errors="replace")
196
+ cache[path] = content
197
+ return content
198
+ except Exception:
199
+ return None
200
+
201
+ def update_snapshot(self) -> None:
202
+ """更新快照为当前文件系统状态。在任务完成后调用。"""
203
+ entries = self.build_snapshot()
204
+ # 保留已有 hash 值避免重复计算
205
+ old = self.load()
206
+ if old:
207
+ for path, entry in entries.items():
208
+ if path in old and old[path].mtime == entry.mtime and old[path].size == entry.size:
209
+ entry.hash = old[path].hash
210
+ self.save(entries)
ai_dev_flow/state.py ADDED
@@ -0,0 +1,87 @@
1
+ """状态文件管理 - .pipeline_stage / .pipeline_note 读写。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger("ai-dev-flow")
9
+
10
+ STAGE_FILE = ".pipeline_stage"
11
+ NOTE_FILE = ".pipeline_note"
12
+ TASK_STATE_FILE = "task_state.json"
13
+
14
+
15
+ def read_stage(ai_dev_dir: Path) -> str | None:
16
+ """读取当前进度,无文件时返回 None。"""
17
+ sf = ai_dev_dir / STAGE_FILE
18
+ if not sf.exists():
19
+ return None
20
+ stage = sf.read_text(encoding="utf-8").strip()
21
+ logger.debug(f"read stage: {stage}")
22
+ return stage
23
+
24
+
25
+ def write_stage(ai_dev_dir: Path, stage: str) -> None:
26
+ """写入当前进度。"""
27
+ sf = ai_dev_dir / STAGE_FILE
28
+ sf.write_text(stage, encoding="utf-8")
29
+ logger.debug(f"write stage: {stage}")
30
+
31
+
32
+ def clear_stage(ai_dev_dir: Path) -> None:
33
+ """删除状态文件(管线全部完成后)。"""
34
+ sf = ai_dev_dir / STAGE_FILE
35
+ if sf.exists():
36
+ sf.unlink()
37
+ logger.debug("stage file removed")
38
+
39
+
40
+ def read_note(ai_dev_dir: Path) -> str | None:
41
+ """读取上次运行留下的笔记。"""
42
+ nf = ai_dev_dir / NOTE_FILE
43
+ if not nf.exists():
44
+ return None
45
+ return nf.read_text(encoding="utf-8").strip()
46
+
47
+
48
+ def write_note(ai_dev_dir: Path, note: str) -> None:
49
+ """写入笔记。"""
50
+ nf = ai_dev_dir / NOTE_FILE
51
+ nf.write_text(note, encoding="utf-8")
52
+ logger.info("note saved")
53
+
54
+
55
+ def clear_note(ai_dev_dir: Path) -> None:
56
+ """删除笔记。"""
57
+ nf = ai_dev_dir / NOTE_FILE
58
+ if nf.exists():
59
+ nf.unlink()
60
+
61
+
62
+ def read_task_state(ai_dev_dir: Path) -> dict | None:
63
+ """读取任务执行状态。返回 None 表示无状态文件。"""
64
+ import json
65
+ sf = ai_dev_dir / TASK_STATE_FILE
66
+ if not sf.exists():
67
+ return None
68
+ return json.loads(sf.read_text(encoding="utf-8"))
69
+
70
+
71
+ def write_task_state(ai_dev_dir: Path, state: dict) -> None:
72
+ """原子写入任务执行状态(写临时文件后重命名)。"""
73
+ import json
74
+ import tempfile
75
+ sf = ai_dev_dir / TASK_STATE_FILE
76
+ tmp = tempfile.NamedTemporaryFile(
77
+ mode="w", encoding="utf-8", delete=False,
78
+ dir=ai_dev_dir, prefix=".task_state_tmp"
79
+ )
80
+ try:
81
+ json.dump(state, tmp, indent=2, ensure_ascii=False)
82
+ tmp.flush()
83
+ tmp.close()
84
+ Path(tmp.name).replace(sf)
85
+ except Exception:
86
+ Path(tmp.name).unlink(missing_ok=True)
87
+ raise