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.
- context_handoff_bundle/__init__.py +2 -0
- context_handoff_bundle/__main__.py +4 -0
- context_handoff_bundle/anchors.py +205 -0
- context_handoff_bundle/autocontext.py +463 -0
- context_handoff_bundle/cli.py +1250 -0
- context_handoff_bundle/compare.py +194 -0
- context_handoff_bundle/drift.py +403 -0
- context_handoff_bundle/freshness.py +156 -0
- context_handoff_bundle/quality.py +221 -0
- context_handoff_bundle/resume.py +257 -0
- context_handoff_bundle/schemas/bundle_metadata.schema.json +20 -0
- context_handoff_bundle/schemas/entities.schema.json +20 -0
- context_handoff_bundle/schemas/evidence_index.schema.json +22 -0
- context_handoff_bundle/schemas/open_questions.schema.json +16 -0
- context_handoff_bundle/schemas/relations.schema.json +17 -0
- context_handoff_bundle/schemas/summary.schema.json +42 -0
- context_handoff_bundle/storage.py +296 -0
- context_handoff_bundle/templates/CONTEXT_HANDOFF.template.md +61 -0
- context_handoff_bundle/templates/bundle_metadata.template.json +10 -0
- context_handoff_bundle/templates/entities.template.json +13 -0
- context_handoff_bundle/templates/evidence_index.template.json +9 -0
- context_handoff_bundle/templates/open_questions.template.json +9 -0
- context_handoff_bundle/templates/relations.template.json +10 -0
- context_handoff_bundle/templates/resume_prompt.template.txt +12 -0
- context_handoff_bundle/templates/summary.template.json +24 -0
- context_handoff_bundle/tokens.py +104 -0
- context_handoff_bundle-0.3.0.dist-info/METADATA +466 -0
- context_handoff_bundle-0.3.0.dist-info/RECORD +32 -0
- context_handoff_bundle-0.3.0.dist-info/WHEEL +5 -0
- context_handoff_bundle-0.3.0.dist-info/entry_points.txt +2 -0
- context_handoff_bundle-0.3.0.dist-info/licenses/LICENSE +21 -0
- context_handoff_bundle-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|