context-handoff-bundle 0.3.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 (32) hide show
  1. context_handoff_bundle/__init__.py +2 -0
  2. context_handoff_bundle/__main__.py +4 -0
  3. context_handoff_bundle/anchors.py +205 -0
  4. context_handoff_bundle/autocontext.py +463 -0
  5. context_handoff_bundle/cli.py +1250 -0
  6. context_handoff_bundle/compare.py +194 -0
  7. context_handoff_bundle/drift.py +403 -0
  8. context_handoff_bundle/freshness.py +156 -0
  9. context_handoff_bundle/quality.py +221 -0
  10. context_handoff_bundle/resume.py +257 -0
  11. context_handoff_bundle/schemas/bundle_metadata.schema.json +20 -0
  12. context_handoff_bundle/schemas/entities.schema.json +20 -0
  13. context_handoff_bundle/schemas/evidence_index.schema.json +22 -0
  14. context_handoff_bundle/schemas/open_questions.schema.json +16 -0
  15. context_handoff_bundle/schemas/relations.schema.json +17 -0
  16. context_handoff_bundle/schemas/summary.schema.json +42 -0
  17. context_handoff_bundle/storage.py +296 -0
  18. context_handoff_bundle/templates/CONTEXT_HANDOFF.template.md +61 -0
  19. context_handoff_bundle/templates/bundle_metadata.template.json +10 -0
  20. context_handoff_bundle/templates/entities.template.json +13 -0
  21. context_handoff_bundle/templates/evidence_index.template.json +9 -0
  22. context_handoff_bundle/templates/open_questions.template.json +9 -0
  23. context_handoff_bundle/templates/relations.template.json +10 -0
  24. context_handoff_bundle/templates/resume_prompt.template.txt +12 -0
  25. context_handoff_bundle/templates/summary.template.json +24 -0
  26. context_handoff_bundle/tokens.py +104 -0
  27. context_handoff_bundle-0.3.0.dist-info/METADATA +466 -0
  28. context_handoff_bundle-0.3.0.dist-info/RECORD +32 -0
  29. context_handoff_bundle-0.3.0.dist-info/WHEEL +5 -0
  30. context_handoff_bundle-0.3.0.dist-info/entry_points.txt +2 -0
  31. context_handoff_bundle-0.3.0.dist-info/licenses/LICENSE +21 -0
  32. context_handoff_bundle-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2 @@
1
+ __all__ = ['main']
2
+ __version__ = '0.1.0'
@@ -0,0 +1,4 @@
1
+ """Allow running as python -m context_handoff_bundle."""
2
+ from context_handoff_bundle.cli import main
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,205 @@
1
+ """Evidence anchor parsing, resolution, and content verification.
2
+
3
+ Evidence anchors are authored by agents as human-readable bullets like:
4
+
5
+ orchestrator/company_loop/builder.py:59 — DEFAULT_BUILDER_CMD (new prompt)
6
+ git commit 4a135d2 on main (pushed)
7
+ https://github.com/remotion-dev/remotion — video lib
8
+ py -3 -m pytest tests/ — verification run
9
+
10
+ Treating those raw strings as filesystem paths is what caused drift to mark
11
+ every anchor GONE. This module is the single shared parser: save uses it to
12
+ verify and hash anchors, load uses it to check them, freshness and token
13
+ estimation use it to resolve files. Parse the locator out, classify the kind,
14
+ and never existence-check prose.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ # Note separators in priority order of first occurrence (em dash, double
25
+ # hyphen, en dash). Everything after the first separator is the note.
26
+ _NOTE_SEPARATORS = (" — ", " -- ", " – ")
27
+
28
+ _COMMIT_RE = re.compile(r"^(?:git\s+)?commit\s+([0-9a-fA-F]{6,40})\b")
29
+ _BARE_COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}$")
30
+ _LINE_SUFFIX_RE = re.compile(r":(\d+)(?:-(\d+))?$")
31
+ # "Memory: C:\path" / "Config: ~/x" style label prefixes before a real path.
32
+ _LABEL_PREFIX_RE = re.compile(r"^[A-Za-z][A-Za-z ]*:\s+(?=[A-Za-z]:[\\/]|[\\/~.]|\w)")
33
+ _BARE_FILENAME_RE = re.compile(r"^[\w.\-]+\.[A-Za-z0-9]+$")
34
+
35
+
36
+ @dataclass
37
+ class Anchor:
38
+ raw: str
39
+ kind: str # 'file' | 'url' | 'commit' | 'other'
40
+ path: str | None = None # file path or commit sha or url
41
+ line_start: int | None = None
42
+ line_end: int | None = None
43
+ note: str = ""
44
+
45
+
46
+ def parse_anchor(raw: str) -> Anchor:
47
+ """Parse a free-form evidence anchor string into a structured Anchor."""
48
+ raw = (raw or "").strip()
49
+ if not raw:
50
+ return Anchor(raw=raw, kind="other")
51
+
52
+ # Split off the note at the earliest separator occurrence.
53
+ locator, note = raw, ""
54
+ cut = min(
55
+ (raw.find(sep) for sep in _NOTE_SEPARATORS if raw.find(sep) != -1),
56
+ default=-1,
57
+ )
58
+ if cut != -1:
59
+ sep_len = next(len(s) for s in _NOTE_SEPARATORS if raw.startswith(s, cut))
60
+ locator, note = raw[:cut].strip(), raw[cut + sep_len :].strip()
61
+
62
+ # URLs first (before any colon/paren surgery).
63
+ if locator.startswith(("http://", "https://")):
64
+ return Anchor(raw=raw, kind="url", path=locator.split(" ")[0], note=note)
65
+
66
+ # Commit references ("git commit 4a135d2 on main", "commit 1eb485f").
67
+ m = _COMMIT_RE.match(locator)
68
+ if m:
69
+ return Anchor(raw=raw, kind="commit", path=m.group(1), note=note)
70
+
71
+ # Drop trailing parentheticals and "; extra" clauses from the locator.
72
+ locator = locator.split(" (")[0].split("; ")[0].strip()
73
+
74
+ # Strip a label prefix like "Memory: C:\path\file.md", but never eat a
75
+ # Windows drive letter ("C:\..." has no space after the colon).
76
+ locator = _LABEL_PREFIX_RE.sub("", locator)
77
+
78
+ if _BARE_COMMIT_RE.match(locator):
79
+ return Anchor(raw=raw, kind="commit", path=locator, note=note)
80
+
81
+ # Pull off a ":line" / ":start-end" suffix.
82
+ line_start = line_end = None
83
+ m = _LINE_SUFFIX_RE.search(locator)
84
+ if m:
85
+ # Guard against eating a Windows drive colon ("C:" with no path).
86
+ candidate = locator[: m.start()]
87
+ if candidate and not re.fullmatch(r"[A-Za-z]", candidate):
88
+ line_start = int(m.group(1))
89
+ line_end = int(m.group(2)) if m.group(2) else None
90
+ locator = candidate
91
+
92
+ # A file locator has no spaces and either contains a path separator or
93
+ # looks like a bare filename with an extension.
94
+ if (
95
+ locator
96
+ and " " not in locator
97
+ and ("/" in locator or "\\" in locator or _BARE_FILENAME_RE.match(locator))
98
+ ):
99
+ return Anchor(
100
+ raw=raw,
101
+ kind="file",
102
+ path=locator,
103
+ line_start=line_start,
104
+ line_end=line_end,
105
+ note=note,
106
+ )
107
+
108
+ return Anchor(raw=raw, kind="other", note=note)
109
+
110
+
111
+ def resolve_anchor_path(
112
+ anchor: Anchor,
113
+ repo_root: Path | str | None = None,
114
+ cwd: Path | str | None = None,
115
+ ) -> Path | None:
116
+ """Resolve a file anchor to an existing file on disk, or None."""
117
+ if anchor.kind != "file" or not anchor.path:
118
+ return None
119
+ candidate = Path(anchor.path)
120
+ candidates = [candidate] if candidate.is_absolute() else []
121
+ if not candidate.is_absolute():
122
+ if repo_root:
123
+ candidates.append(Path(repo_root) / candidate)
124
+ if cwd:
125
+ candidates.append(Path(cwd) / candidate)
126
+ for c in candidates:
127
+ try:
128
+ if c.is_file():
129
+ return c.resolve()
130
+ except OSError:
131
+ continue
132
+ return None
133
+
134
+
135
+ def hash_anchor_content(
136
+ path: Path,
137
+ line_start: int | None,
138
+ line_end: int | None,
139
+ ) -> str | None:
140
+ """Content hash for an anchor: the referenced line range, or the whole
141
+ file when no lines are given. Line-range hashing means unrelated edits
142
+ elsewhere in the file do not invalidate the anchor."""
143
+ try:
144
+ if line_start is None:
145
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
146
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
147
+ end = line_end if line_end is not None else line_start
148
+ segment = "\n".join(lines[line_start - 1 : end])
149
+ return hashlib.sha256(segment.encode("utf-8")).hexdigest()[:16]
150
+ except OSError:
151
+ return None
152
+
153
+
154
+ def normalize_for_match(path_str: str) -> str:
155
+ """Normalize a path for exact comparison against git diff output
156
+ (forward slashes, lowercased -- git paths are repo-relative and we only
157
+ use this for matching, never for display)."""
158
+ return path_str.replace("\\", "/").strip("/").lower()
159
+
160
+
161
+ def repo_relative(resolved: Path, repo_root: Path | str | None) -> str | None:
162
+ """Repo-relative normalized form of a resolved path, or None if the file
163
+ is outside the repo."""
164
+ if not repo_root:
165
+ return None
166
+ try:
167
+ rel = resolved.relative_to(Path(repo_root).resolve())
168
+ except ValueError:
169
+ return None
170
+ return normalize_for_match(str(rel))
171
+
172
+
173
+ # Re-exported convenience used by save: parse + resolve + hash in one pass.
174
+ def verify_anchor(
175
+ raw: str,
176
+ repo_root: Path | str | None = None,
177
+ cwd: Path | str | None = None,
178
+ ) -> dict:
179
+ """Classify and (for file anchors) verify an anchor at save time.
180
+
181
+ Returns a dict of the optional evidence-index fields:
182
+ anchor_kind, resolved_path (repo-relative when possible),
183
+ line_start, line_end, content_hash, verified_at_save
184
+ """
185
+ anchor = parse_anchor(raw)
186
+ out: dict = {
187
+ "anchor_kind": anchor.kind,
188
+ "resolved_path": None,
189
+ "line_start": anchor.line_start,
190
+ "line_end": anchor.line_end,
191
+ "content_hash": None,
192
+ "verified_at_save": False,
193
+ }
194
+ if anchor.kind != "file":
195
+ return out
196
+ resolved = resolve_anchor_path(anchor, repo_root=repo_root, cwd=cwd)
197
+ if resolved is None:
198
+ return out
199
+ rel = repo_relative(resolved, repo_root)
200
+ out["resolved_path"] = rel if rel is not None else str(resolved)
201
+ out["content_hash"] = hash_anchor_content(
202
+ resolved, anchor.line_start, anchor.line_end
203
+ )
204
+ out["verified_at_save"] = out["content_hash"] is not None
205
+ return out
@@ -0,0 +1,463 @@
1
+ """Auto-context gathering for rich handoff generation without manual notes.
2
+
3
+ Task-aware: captures what was being worked on, what changed, what broke,
4
+ what remains unresolved, and which files matter first -- not just repo orientation.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import glob
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+
13
+ def gather_repo_context(cwd: Path | None = None) -> dict:
14
+ """Gather rich, task-aware context from the current repo automatically.
15
+
16
+ Returns a parsed dict compatible with the notes format:
17
+ scope, projects, findings, opportunities, open_questions, evidence_anchors
18
+ """
19
+ cwd = cwd or Path.cwd()
20
+ cwd = cwd.resolve()
21
+
22
+ scope = _build_scope(cwd)
23
+ projects = _detect_projects(cwd)
24
+ findings = _gather_findings(cwd)
25
+ evidence = _gather_evidence_anchors(cwd)
26
+ open_questions = _gather_open_questions(cwd, findings)
27
+
28
+ return {
29
+ 'scope': scope,
30
+ 'projects': projects,
31
+ 'findings': findings,
32
+ 'opportunities': [],
33
+ 'open_questions': open_questions,
34
+ 'evidence_anchors': evidence,
35
+ }
36
+
37
+
38
+ def _build_scope(cwd: Path) -> str:
39
+ """Build a scope description from repo state."""
40
+ parts = [f'Context handoff from {cwd.name}']
41
+ branch = _git_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
42
+ if branch:
43
+ parts.append(f'on branch {branch}')
44
+ return ' '.join(parts)
45
+
46
+
47
+ def _detect_projects(cwd: Path) -> list[str]:
48
+ """Detect project names from repo structure."""
49
+ projects = [cwd.name]
50
+ for subdir in ['packages', 'projects', 'apps', 'services', 'modules']:
51
+ d = cwd / subdir
52
+ if d.is_dir():
53
+ for child in sorted(d.iterdir()):
54
+ if child.is_dir() and not child.name.startswith('.'):
55
+ projects.append(child.name)
56
+ return projects
57
+
58
+
59
+ def _gather_findings(cwd: Path) -> list[str]:
60
+ """Extract task-aware findings from repo inspection."""
61
+ findings: list[str] = []
62
+
63
+ # 1. What the project is
64
+ readme_desc = _extract_readme_purpose(cwd)
65
+ if readme_desc:
66
+ findings.append(readme_desc)
67
+
68
+ # 2. What branch work looks like
69
+ branch_summary = _summarize_branch_work(cwd)
70
+ if branch_summary:
71
+ findings.append(branch_summary)
72
+
73
+ # 3. What was being worked on (recent commit themes)
74
+ work_focus = _detect_work_focus(cwd)
75
+ if work_focus:
76
+ findings.append(work_focus)
77
+
78
+ # 4. What changed recently (diff stats)
79
+ diff_summary = _summarize_recent_diff(cwd)
80
+ if diff_summary:
81
+ findings.append(diff_summary)
82
+
83
+ # 5. What's in progress right now
84
+ wip = _detect_work_in_progress(cwd)
85
+ if wip:
86
+ findings.append(wip)
87
+
88
+ # 6. Technology stack
89
+ stack = _detect_tech_stack(cwd)
90
+ if stack:
91
+ findings.append(f'Technology stack: {", ".join(stack)}')
92
+
93
+ # 7. Project structure
94
+ structure = _summarize_structure(cwd)
95
+ if structure:
96
+ findings.append(structure)
97
+
98
+ # 8. What files were touched most recently
99
+ hot_files = _get_hot_files(cwd)
100
+ if hot_files:
101
+ findings.append(f'Hot files (most recently changed): {", ".join(hot_files[:6])}')
102
+
103
+ return findings
104
+
105
+
106
+ def _gather_evidence_anchors(cwd: Path) -> list[str]:
107
+ """Gather key files as evidence anchors, prioritizing recently changed files."""
108
+ anchors: list[str] = []
109
+
110
+ # Recently changed files first -- these are most relevant
111
+ hot = _get_hot_files(cwd)
112
+ for f in hot[:5]:
113
+ p = cwd / f
114
+ if p.exists():
115
+ anchors.append(str(p))
116
+
117
+ # Key documentation files
118
+ for name in ['README.md', 'CLAUDE.md', 'docs/SPEC.md', 'docs/ROADMAP.md',
119
+ 'docs/ARCHITECTURE.md', 'package.json', 'pyproject.toml',
120
+ 'Cargo.toml', 'go.mod', 'pom.xml']:
121
+ p = cwd / name
122
+ if p.exists():
123
+ anchors.append(str(p))
124
+
125
+ # Source entry points
126
+ for pattern in ['src/index.*', 'src/main.*', 'src/app.*', 'src/lib.*',
127
+ 'src/*/cli.py', 'src/*/main.py', 'src/*/__init__.py',
128
+ 'main.*', 'index.*', 'app.*']:
129
+ matches = glob.glob(str(cwd / pattern))
130
+ for m in matches[:3]:
131
+ anchors.append(m)
132
+
133
+ # Dirty files -- these are actively being worked on
134
+ dirty = _get_dirty_files(cwd)
135
+ for f in dirty[:5]:
136
+ p = cwd / f
137
+ if p.exists():
138
+ anchors.append(str(p))
139
+
140
+ return list(dict.fromkeys(anchors)) # Deduplicate preserving order
141
+
142
+
143
+ def _gather_open_questions(cwd: Path, findings: list[str]) -> list[str]:
144
+ """Generate honest open questions based on what we don't know."""
145
+ questions: list[str] = []
146
+
147
+ dirty = _get_dirty_files(cwd)
148
+ if dirty:
149
+ questions.append(f'There are {len(dirty)} uncommitted file(s) -- what is their state? Ready to commit or still in progress?')
150
+
151
+ # Detect potential issues
152
+ failing_tests = _detect_test_failures(cwd)
153
+ if failing_tests:
154
+ questions.append(f'Tests may be failing: {failing_tests}')
155
+
156
+ todo_count = _count_todos(cwd)
157
+ if todo_count > 0:
158
+ questions.append(f'{todo_count} TODO/FIXME comments in the codebase -- which are relevant to the current work?')
159
+
160
+ # Branch-specific questions
161
+ branch = _git_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
162
+ if branch and branch not in ('main', 'master'):
163
+ questions.append(f'Branch "{branch}" -- is it ready to merge, or still in progress?')
164
+
165
+ questions.append('What was the immediate next step before this handoff?')
166
+
167
+ return questions
168
+
169
+
170
+ # ── Task-awareness helpers ──
171
+
172
+ def _summarize_branch_work(cwd: Path) -> str:
173
+ """Summarize what this branch has done vs main."""
174
+ branch = _git_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
175
+ if not branch or branch in ('main', 'master'):
176
+ return ''
177
+
178
+ # Count commits ahead of main
179
+ for base in ['main', 'master']:
180
+ count = _git_output(['git', 'rev-list', '--count', f'{base}..HEAD'], cwd)
181
+ if count and count != '0':
182
+ # Get the commit subjects
183
+ subjects = _git_output(
184
+ ['git', 'log', f'{base}..HEAD', '--oneline', '--no-decorate', '--max-count=5'],
185
+ cwd
186
+ )
187
+ if subjects:
188
+ lines = subjects.splitlines()
189
+ summary = '; '.join(lines[:4])
190
+ extra = f'; and {len(lines) - 4} more' if len(lines) > 4 else ''
191
+ return f'Branch "{branch}" is {count} commit(s) ahead of {base}: {summary}{extra}'
192
+ return ''
193
+
194
+
195
+ def _detect_work_focus(cwd: Path) -> str:
196
+ """Detect what kind of work was happening from recent commits and file paths.
197
+
198
+ Combines commit messages with file-path analysis to produce specific
199
+ descriptions like "CLI commands and quality scoring" instead of generic
200
+ theme keywords like "add, update, fix".
201
+ """
202
+ # Get recent commit messages
203
+ log = _git_output(
204
+ ['git', 'log', '--max-count=8', '--format=%s', '--no-decorate'],
205
+ cwd
206
+ )
207
+ # Get recently changed file paths
208
+ files_output = _git_output(
209
+ ['git', 'log', '--max-count=5', '--name-only', '--pretty=format:'],
210
+ cwd
211
+ )
212
+
213
+ if not log and not files_output:
214
+ return ''
215
+
216
+ messages = log.splitlines() if log else []
217
+
218
+ # Analyze file paths to detect code areas
219
+ areas: dict[str, int] = {}
220
+ if files_output:
221
+ for filepath in files_output.splitlines():
222
+ filepath = filepath.strip()
223
+ if not filepath:
224
+ continue
225
+ area = _classify_file_area(filepath)
226
+ if area:
227
+ areas[area] = areas.get(area, 0) + 1
228
+
229
+ # Build description
230
+ parts: list[str] = []
231
+
232
+ # Top code areas from file paths (the specific part)
233
+ if areas:
234
+ sorted_areas = sorted(areas.items(), key=lambda x: x[1], reverse=True)
235
+ top_areas = [a for a, _ in sorted_areas[:4]]
236
+ parts.append(f'Active areas: {", ".join(top_areas)}')
237
+
238
+ # Recent commit summaries (the narrative part)
239
+ if messages:
240
+ parts.append(f'Last commits: {"; ".join(messages[:3])}')
241
+
242
+ return '. '.join(parts) if parts else ''
243
+
244
+
245
+ def _classify_file_area(filepath: str) -> str:
246
+ """Classify a file path into a meaningful code area name."""
247
+ parts = filepath.replace('\\', '/').split('/')
248
+ filename = parts[-1] if parts else filepath
249
+
250
+ # Strip common prefixes
251
+ meaningful = [p for p in parts if p not in ('src', 'lib', 'app', 'pkg', 'internal', 'cmd')]
252
+
253
+ # Use the module/package name if available
254
+ if len(meaningful) >= 2:
255
+ # e.g., "context_handoff_bundle/quality.py" -> "quality"
256
+ module = meaningful[-1].rsplit('.', 1)[0] # Remove extension
257
+ parent = meaningful[-2]
258
+ # Skip __init__, __main__ etc
259
+ if module.startswith('__'):
260
+ return parent
261
+ return module
262
+
263
+ # For top-level files, use the file purpose
264
+ name = filename.rsplit('.', 1)[0]
265
+ purpose_map = {
266
+ 'test': 'tests', 'spec': 'tests', 'conftest': 'test config',
267
+ 'readme': 'docs', 'changelog': 'docs', 'contributing': 'docs',
268
+ 'dockerfile': 'docker', 'docker-compose': 'docker',
269
+ 'makefile': 'build', 'setup': 'build config', 'pyproject': 'build config',
270
+ 'package': 'package config', 'tsconfig': 'typescript config',
271
+ 'gitignore': 'git config', 'eslintrc': 'lint config',
272
+ }
273
+ lower_name = name.lower()
274
+ for key, label in purpose_map.items():
275
+ if key in lower_name:
276
+ return label
277
+
278
+ return name if name else ''
279
+
280
+
281
+ def _summarize_recent_diff(cwd: Path) -> str:
282
+ """Summarize what changed in recent commits by file count and areas."""
283
+ stat = _git_output(
284
+ ['git', 'diff', '--stat', '--stat-count=10', 'HEAD~3..HEAD'],
285
+ cwd
286
+ )
287
+ if not stat:
288
+ # Try with fewer commits
289
+ stat = _git_output(['git', 'diff', '--stat', '--stat-count=10', 'HEAD~1..HEAD'], cwd)
290
+ if not stat:
291
+ return ''
292
+
293
+ lines = stat.strip().splitlines()
294
+ if lines:
295
+ # Last line is the summary (e.g., "10 files changed, 200 insertions(+), 50 deletions(-)")
296
+ summary_line = lines[-1].strip()
297
+ if 'changed' in summary_line:
298
+ return f'Recent changes: {summary_line}'
299
+ return ''
300
+
301
+
302
+ def _detect_work_in_progress(cwd: Path) -> str:
303
+ """Detect uncommitted work and characterize it."""
304
+ dirty = _get_dirty_files(cwd)
305
+ if not dirty:
306
+ return ''
307
+
308
+ # Categorize dirty files
309
+ staged = _git_output(['git', 'diff', '--name-only', '--cached'], cwd)
310
+ unstaged = _git_output(['git', 'diff', '--name-only'], cwd)
311
+ untracked = _git_output(['git', 'ls-files', '--others', '--exclude-standard'], cwd)
312
+
313
+ parts = []
314
+ if staged:
315
+ staged_count = len(staged.splitlines())
316
+ parts.append(f'{staged_count} staged')
317
+ if unstaged:
318
+ unstaged_count = len(unstaged.splitlines())
319
+ parts.append(f'{unstaged_count} modified')
320
+ if untracked:
321
+ untracked_count = len(untracked.splitlines())
322
+ parts.append(f'{untracked_count} untracked')
323
+
324
+ file_list = ', '.join(dirty[:5])
325
+ extra = f' and {len(dirty) - 5} more' if len(dirty) > 5 else ''
326
+ return f'Work in progress ({", ".join(parts)}): {file_list}{extra}'
327
+
328
+
329
+ def _detect_test_failures(cwd: Path) -> str:
330
+ """Quick check for obvious test failure indicators."""
331
+ # Look for common test result files
332
+ for pattern in ['.pytest_cache/v/cache/lastfailed', 'test-results.xml']:
333
+ p = cwd / pattern
334
+ if p.exists():
335
+ try:
336
+ content = p.read_text(encoding='utf-8')
337
+ if content.strip() and content.strip() != '{}':
338
+ return 'pytest lastfailed cache is non-empty -- tests may have been failing'
339
+ except Exception:
340
+ pass
341
+ return ''
342
+
343
+
344
+ def _get_hot_files(cwd: Path) -> list[str]:
345
+ """Get the most actively changed files across recent commits + dirty state."""
346
+ files: list[str] = []
347
+
348
+ # Files changed in last 5 commits
349
+ output = _git_output(
350
+ ['git', 'log', '--max-count=5', '--name-only', '--pretty=format:'],
351
+ cwd
352
+ )
353
+ if output:
354
+ for f in output.splitlines():
355
+ f = f.strip()
356
+ if f and f not in files:
357
+ files.append(f)
358
+
359
+ # Dirty files
360
+ dirty = _get_dirty_files(cwd)
361
+ for f in dirty:
362
+ if f not in files:
363
+ files.insert(0, f) # Dirty files are hottest
364
+
365
+ return files[:15]
366
+
367
+
368
+ # ── Base helpers ──
369
+
370
+ def _git_output(cmd: list[str], cwd: Path) -> str:
371
+ try:
372
+ return subprocess.check_output(
373
+ cmd, cwd=str(cwd), stderr=subprocess.DEVNULL, text=True
374
+ ).strip()
375
+ except Exception:
376
+ return ''
377
+
378
+
379
+ def _extract_readme_purpose(cwd: Path) -> str:
380
+ readme = cwd / 'README.md'
381
+ if not readme.exists():
382
+ return ''
383
+ try:
384
+ text = readme.read_text(encoding='utf-8')
385
+ lines = text.splitlines()
386
+ content_lines: list[str] = []
387
+ past_title = False
388
+ for line in lines:
389
+ if line.startswith('# ') and not past_title:
390
+ past_title = True
391
+ continue
392
+ if past_title and line.strip():
393
+ content_lines.append(line.strip())
394
+ if len(content_lines) >= 3:
395
+ break
396
+ elif past_title and content_lines:
397
+ break
398
+ if content_lines:
399
+ desc = ' '.join(content_lines)
400
+ if len(desc) > 300:
401
+ desc = desc[:297] + '...'
402
+ return desc
403
+ except Exception:
404
+ pass
405
+ return ''
406
+
407
+
408
+ def _detect_tech_stack(cwd: Path) -> list[str]:
409
+ stack: list[str] = []
410
+ indicators = {
411
+ 'package.json': 'Node.js', 'pyproject.toml': 'Python',
412
+ 'Cargo.toml': 'Rust', 'go.mod': 'Go',
413
+ 'pom.xml': 'Java/Maven', 'build.gradle': 'Java/Gradle',
414
+ 'Gemfile': 'Ruby', 'composer.json': 'PHP',
415
+ 'tsconfig.json': 'TypeScript', 'next.config.js': 'Next.js',
416
+ 'next.config.ts': 'Next.js', 'vite.config.ts': 'Vite',
417
+ 'Dockerfile': 'Docker', 'docker-compose.yml': 'Docker Compose',
418
+ }
419
+ for filename, tech in indicators.items():
420
+ if (cwd / filename).exists():
421
+ stack.append(tech)
422
+ return stack
423
+
424
+
425
+ def _summarize_structure(cwd: Path) -> str:
426
+ dirs: list[str] = []
427
+ for item in sorted(cwd.iterdir()):
428
+ if item.is_dir() and not item.name.startswith('.'):
429
+ dirs.append(item.name)
430
+ if not dirs:
431
+ return ''
432
+ return f'Top-level directories: {", ".join(dirs[:12])}'
433
+
434
+
435
+ def _get_dirty_files(cwd: Path) -> list[str]:
436
+ output = _git_output(['git', 'status', '--porcelain', '--short'], cwd)
437
+ if not output:
438
+ return []
439
+ files: list[str] = []
440
+ for line in output.splitlines():
441
+ parts = line.strip().split(None, 1)
442
+ if len(parts) == 2:
443
+ files.append(parts[1])
444
+ return files[:20]
445
+
446
+
447
+ def _count_todos(cwd: Path) -> int:
448
+ try:
449
+ output = subprocess.check_output(
450
+ ['git', 'grep', '-c', '-E', r'TODO|FIXME'],
451
+ cwd=str(cwd), stderr=subprocess.DEVNULL, text=True
452
+ )
453
+ total = 0
454
+ for line in output.splitlines():
455
+ parts = line.rsplit(':', 1)
456
+ if len(parts) == 2:
457
+ try:
458
+ total += int(parts[1])
459
+ except ValueError:
460
+ pass
461
+ return total
462
+ except Exception:
463
+ return 0