Skip to content

Commit f825557

Browse files
sync: comprehensive audit fixes — PYTHONPATH, SQL injection, era mapper, test coverage
sync: comprehensive audit fixes — PYTHONPATH, SQL injection, era mapper, test coverage
2 parents 9638ae6 + eb8f858 commit f825557

28 files changed

Lines changed: 4075 additions & 137 deletions

.cursorrules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
- Epoch only works if everyone contributes estimate-vs-actual data
4949

5050
## Local LLM
51-
- Use local inference at 100.66.225.85:1234 before cloud APIs
51+
- Use local inference at localhost:1234 before cloud APIs
5252
- Check loaded models first, don't touch models you didn't load
5353
- Unload when done
5454
- CPU thread pool: 10, flash attention: on, KV cache: Q8

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
- Epoch only works if everyone contributes estimate-vs-actual data
4949

5050
## Local LLM
51-
- Use local inference at 100.66.225.85:1234 before cloud APIs
51+
- Use local inference at localhost:1234 before cloud APIs
5252
- Check loaded models first, don't touch models you didn't load
5353
- Unload when done
5454
- CPU thread pool: 10, flash attention: on, KV cache: Q8

.windsurfrules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
- Epoch only works if everyone contributes estimate-vs-actual data
4949

5050
## Local LLM
51-
- Use local inference at 100.66.225.85:1234 before cloud APIs
51+
- Use local inference at localhost:1234 before cloud APIs
5252
- Check loaded models first, don't touch models you didn't load
5353
- Unload when done
5454
- CPU thread pool: 10, flash attention: on, KV cache: Q8

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ Create a private repo in Pastorsimon1798's personal account with:
316316

317317
## Local-First Inference (LM Studio)
318318

319-
All KyaniteLabs projects that require an LLM must use local inference first. Server runs on Tailscale at `100.66.225.85:1234`.
319+
All KyaniteLabs projects that require an LLM must use local inference first. The Mac-local LM Studio compatibility endpoint is `http://localhost:1234`, backed by the NucBox LiteLLM server over an SSH tunnel. The old Windows/Tailscale endpoint `100.66.225.85:1234` is retired unless Tailscale is explicitly restored.
320320

321321
### Server Specs
322322
- **CPU**: AMD Ryzen AI Max 395 (Strix Halo) — 16 cores, 32 threads

archaeology/analysis_runner.py

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,53 @@ def run_ml_pattern_mapper(self) -> dict[str, Any]:
167167
},
168168
}
169169

170+
def _approximate_sessions(self) -> list[dict]:
171+
"""Approximate sessions from commits when sessions table is absent.
172+
173+
Groups commits into sessions using a 2-hour inactivity gap heuristic.
174+
Falls back to daily grouping if timestamps lack time components.
175+
"""
176+
tables = {r["name"] for r in self._query_db("SELECT name FROM sqlite_master WHERE type='table'")}
177+
if "sessions" in tables:
178+
return self._query_db("SELECT session_id, timestamp FROM sessions ORDER BY timestamp")
179+
180+
commits = self._query_db("SELECT date FROM commits ORDER BY date")
181+
if not commits:
182+
return []
183+
184+
from datetime import datetime as dt
185+
GAP_HOURS = 2
186+
sessions: list[dict] = []
187+
session_start = None
188+
prev_ts = None
189+
190+
for row in commits:
191+
raw = row.get("date", "")
192+
try:
193+
ts = dt.fromisoformat(raw[:19])
194+
except (ValueError, TypeError):
195+
ts = None
196+
197+
if ts is None:
198+
day = raw[:10]
199+
if day != (prev_ts or ""):
200+
sessions.append({"session_id": day, "timestamp": day})
201+
prev_ts = day
202+
continue
203+
204+
if prev_ts is None or (ts - prev_ts).total_seconds() > GAP_HOURS * 3600:
205+
session_id = ts.strftime("%Y%m%d-%H%M%S")
206+
sessions.append({"session_id": session_id, "timestamp": ts.isoformat()})
207+
session_start = ts
208+
209+
prev_ts = ts
210+
211+
return sessions
212+
170213
def run_agentic_workflow(self) -> dict[str, Any]:
171214
"""Analyze AI agent interaction patterns."""
172215
self._log("Running Agentic Workflow Analyzer...")
173-
sessions = self._query_db("SELECT session_id, timestamp FROM sessions ORDER BY timestamp")
216+
sessions = self._approximate_sessions()
174217
hooks = self._like_commits(["hook", "pre-commit", "post-commit", "automation"], 50)
175218
agent_commits = self._query_db("SELECT author, COUNT(*) as cnt FROM commits GROUP BY author ORDER BY cnt DESC")
176219
return {
@@ -237,20 +280,79 @@ def run_source_archaeologist(self) -> dict[str, Any]:
237280
date = str(row.get("date", ""))[:7]
238281
if date:
239282
by_month[date] += 1
240-
improvements = [
241-
{"rank": 1, "title": "Keep audit gate as release blocker", "effort": "M", "impact": "HIGH"},
242-
{"rank": 2, "title": "Replace placeholder analytics with derived joins", "effort": "M", "impact": "HIGH"},
243-
{"rank": 3, "title": "Continue splitting large evaluator/router surfaces", "effort": "L", "impact": "MEDIUM"},
244-
]
283+
hotspots = self._query_db("SELECT message, COUNT(*) as cnt FROM commits GROUP BY message ORDER BY cnt DESC LIMIT 10")
284+
improvements = self._derive_improvements(quality, large_change, todo, hotspots)
245285
return {
246286
"analysis_metadata": {"timestamp": datetime.now().isoformat(), "analyst": "Automated Source Code Archaeologist", "project": self.project_name, "commit_count": self._commit_count()},
247287
"quality_trajectory": {"assessment": "IMPROVING" if quality else "UNKNOWN", "evidence_count": len(quality), "by_month": dict(sorted(by_month.items()))},
248288
"architecture_drift": {"large_change_signals": large_change[:10], "todo_or_stub_signals": todo[:10]},
249-
"hotspots": self._query_db("SELECT message, COUNT(*) as cnt FROM commits GROUP BY message ORDER BY cnt DESC LIMIT 10"),
289+
"hotspots": hotspots,
250290
"improvements": improvements,
251291
"summary": {"quality_signal_count": len(quality), "large_change_signal_count": len(large_change), "todo_signal_count": len(todo)},
252292
}
253293

294+
def _derive_improvements(
295+
self,
296+
quality: list[dict],
297+
large_change: list[dict],
298+
todo: list[dict],
299+
hotspots: list[dict],
300+
) -> list[dict]:
301+
"""Derive prioritized remediation recommendations from actual commit data."""
302+
items: list[tuple[int, str, str, str]] = [] # (score, title, effort, impact)
303+
304+
# Flapping issues: repeated commit messages signal unresolved root causes
305+
flapping = [h for h in hotspots if h.get("cnt", 0) >= 3]
306+
if flapping:
307+
top_msg = str(flapping[0].get("message", ""))[:60]
308+
items.append((
309+
100,
310+
f"Fix recurring issue: {top_msg}",
311+
"M", "HIGH",
312+
))
313+
314+
# Unresolved stubs / TODOs
315+
if todo:
316+
items.append((
317+
90 if len(todo) >= 5 else 70,
318+
f"Resolve {len(todo)} stub or placeholder commit(s)",
319+
"S", "HIGH" if len(todo) >= 5 else "MEDIUM",
320+
))
321+
322+
# Decomposition momentum: carry it through
323+
if large_change:
324+
items.append((
325+
60,
326+
f"Continue decomposition — {len(large_change)} large-change signal(s) detected",
327+
"L", "MEDIUM",
328+
))
329+
330+
# Quality signal density: low fix/test ratio suggests coverage gaps
331+
commit_count = self._commit_count() or 1
332+
quality_ratio = len(quality) / commit_count
333+
if quality_ratio < 0.10:
334+
items.append((
335+
80,
336+
f"Boost quality signal density — fix/test ratio at {quality_ratio:.0%} (target ≥10%)",
337+
"M", "HIGH",
338+
))
339+
elif quality_ratio < 0.20:
340+
items.append((
341+
50,
342+
f"Maintain quality signal density — currently at {quality_ratio:.0%}",
343+
"S", "LOW",
344+
))
345+
346+
# No issues found: project is healthy
347+
if not items:
348+
items.append((10, "No critical remediation items — maintain current trajectory", "S", "LOW"))
349+
350+
items.sort(key=lambda x: x[0], reverse=True)
351+
return [
352+
{"rank": i + 1, "title": title, "effort": effort, "impact": impact}
353+
for i, (_, title, effort, impact) in enumerate(items)
354+
]
355+
254356
def run_youtube_correlator(self) -> dict[str, Any]:
255357
"""Summarize YouTube/watch-history correlation artifacts when available."""
256358
self._log("Running YouTube Correlator...")

archaeology/cli.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ def demo(project_name, force, build_db):
8181
click.echo(f"Then: archaeology audit {project_name} --fail-on HIGH")
8282
if build_db:
8383
cmd = [sys.executable, "-m", "archaeology.db.builder", "--project-root", str(project_root)]
84-
result = subprocess.run(cmd, check=True, timeout=300)
84+
_env = os.environ.copy()
85+
_pkg_root = str(Path(__file__).parent.parent)
86+
_env["PYTHONPATH"] = _pkg_root + ((":" + _env["PYTHONPATH"]) if _env.get("PYTHONPATH") else "")
87+
result = subprocess.run(cmd, check=True, timeout=300, env=_env)
8588
if result.returncode != 0:
8689
raise click.exceptions.Exit(result.returncode)
8790

@@ -138,7 +141,10 @@ def build_db(project_name, verbose):
138141
if verbose:
139142
cmd.append("--verbose")
140143

141-
result = subprocess.run(cmd, check=True, timeout=300)
144+
_env = os.environ.copy()
145+
_pkg_root = str(Path(__file__).parent.parent)
146+
_env["PYTHONPATH"] = _pkg_root + ((":" + _env["PYTHONPATH"]) if _env.get("PYTHONPATH") else "")
147+
result = subprocess.run(cmd, check=True, timeout=300, env=_env)
142148
if result.returncode == 0 and os.path.exists(db_path):
143149
click.echo(f"Database built at {db_path}")
144150
else:
@@ -235,12 +241,17 @@ def signals(project_name, config_path, min_gap_days, verbose):
235241
if min_gap_days is not None:
236242
config["min_gap_days"] = min_gap_days
237243

244+
db_path = os.path.join(_project_dir(project_name), "data", "archaeology.db")
245+
if not os.path.exists(db_path):
246+
click.echo(f"No database found. Run 'archaeology build-db {project_name}' first.", err=True)
247+
sys.exit(1)
248+
238249
result = detect_signals(project_name, config=config or None)
239250
if result.get("signals"):
240251
click.echo(f"Detected {len(result['signals'])} signals "
241252
f"across {len(result['cluster_summary'])} clusters.")
242253
else:
243-
click.echo("No signals detected. Build the database first.")
254+
click.echo("No significant patterns detected in the commit history.")
244255

245256

246257
@main.command()
@@ -438,6 +449,7 @@ def visualize(project_name):
438449
first_date = ""
439450
last_date = ""
440451
agent_count = 0
452+
eras_data = None
441453
eras_json = os.path.join(project_dir, "data", "commit-eras.json")
442454
if os.path.exists(eras_json):
443455
try:
@@ -518,7 +530,17 @@ def visualize(project_name):
518530
# Inline data.json so the HTML works from file:// (no CORS issues)
519531
if os.path.exists(data_json):
520532
with open(data_json, encoding="utf-8") as f:
521-
data_content = f.read()
533+
data_payload = json.load(f)
534+
535+
# Merge commit_eras and top-level fields from commit-eras.json into PROJECT_DATA
536+
# so the era timeline visualization has real data to render.
537+
if eras_data is not None:
538+
data_payload.setdefault("commit_eras", eras_data.get("eras", []))
539+
data_payload.setdefault("total_commits", eras_data.get("total_commits", 0))
540+
data_payload.setdefault("first_commit_date", eras_data.get("first_commit_date", ""))
541+
data_payload.setdefault("last_commit_date", eras_data.get("last_commit_date", ""))
542+
543+
data_content = json.dumps(data_payload)
522544
safe_data_content = data_content.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
523545
inline_script = f'<script>window.PROJECT_DATA = {safe_data_content}; window.dispatchEvent(new Event("data-loaded"));</script>'
524546
html = html.replace(
@@ -634,7 +656,10 @@ def cascade(project_name, dry_run, skip_mine):
634656
db_path = data_dir / "archaeology.db"
635657
cmd = [sys.executable, "-m", "archaeology.db.builder",
636658
"--project-root", str(project_dir)]
637-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
659+
_env = os.environ.copy()
660+
_pkg_root = str(Path(__file__).parent.parent)
661+
_env["PYTHONPATH"] = _pkg_root + ((":" + _env["PYTHONPATH"]) if _env.get("PYTHONPATH") else "")
662+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=_env)
638663
if result.returncode == 0:
639664
click.echo(f" Database built ({db_path})")
640665
else:
@@ -966,7 +991,10 @@ def sync(projects, skip_mine, skip_signals, verbose):
966991
if verbose:
967992
cmd.append("--verbose")
968993

969-
result = subprocess.run(cmd, capture_output=not verbose, check=True, timeout=300)
994+
_env = os.environ.copy()
995+
_pkg_root = str(Path(__file__).parent.parent)
996+
_env["PYTHONPATH"] = _pkg_root + ((":" + _env["PYTHONPATH"]) if _env.get("PYTHONPATH") else "")
997+
result = subprocess.run(cmd, capture_output=not verbose, check=True, timeout=300, env=_env)
970998
if result.returncode == 0 and os.path.exists(db_path):
971999
click.echo(f" DB built")
9721000
else:
@@ -1162,7 +1190,7 @@ def benchmark(project_name):
11621190
sys.exit(1)
11631191

11641192

1165-
@main.command()
1193+
@main.command("dashboard")
11661194
@click.option("--port", default=8080, help="Port to serve on")
11671195
@click.option("--no-open", is_flag=True, help="Don't open browser automatically")
11681196
def serve(port, no_open):

archaeology/era_mapper.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,31 +46,55 @@ def _infer_year(raw: dict) -> int:
4646
return datetime.now().year
4747

4848

49+
def _parse_era_date(date_str: str, year: int, reference: datetime | None = None) -> datetime | None:
50+
"""Parse a single date string supporting multiple formats."""
51+
s = date_str.strip()
52+
# ISO: 2026-01-15
53+
for fmt in ("%Y-%m-%d", "%Y-%m", "%b %d %Y", "%b %d"):
54+
try:
55+
if fmt == "%b %d":
56+
dt = datetime.strptime(f"{s} {year}", "%b %d %Y")
57+
else:
58+
dt = datetime.strptime(s, fmt)
59+
return dt
60+
except ValueError:
61+
continue
62+
return None
63+
64+
4965
def load_eras(eras_path: Path) -> list[EraDef]:
50-
"""Load era definitions from commit-eras.json."""
66+
"""Load era definitions from commit-eras.json.
67+
68+
Handles date formats: "Jan 1 - Jan 5", "2026-01-01 to 2026-01-05",
69+
ISO single dates (era spans to next day), and month-only ranges.
70+
"""
5171
if not eras_path.exists():
5272
return []
73+
import re as _re
5374
raw = json.loads(eras_path.read_text())
54-
# Infer year from the first commit date in the data
5575
year = _infer_year(raw)
5676
eras = []
5777
for era in raw.get("eras", []):
5878
dates = era.get("dates", "")
59-
parts = dates.split(" - ") if " - " in dates else dates.split(" – ")
60-
if len(parts) != 2:
61-
continue
62-
try:
63-
start = datetime.strptime(f"{parts[0].strip()} {year}", "%b %d %Y")
64-
# If end date month is earlier than start, it's next year
65-
end = datetime.strptime(f"{parts[1].strip()} {year}", "%b %d %Y")
66-
if end < start:
67-
end = datetime.strptime(f"{parts[1].strip()} {year + 1}", "%b %d %Y")
68-
except (ValueError, IndexError):
79+
# Split on " - ", " – ", " to " (ISO range), or handle single dates
80+
for sep in (" - ", " – ", " to "):
81+
if sep in dates:
82+
parts = dates.split(sep, 1)
83+
break
84+
else:
85+
parts = [dates, dates] # single date → era spans that day
86+
87+
start = _parse_era_date(parts[0], year)
88+
end = _parse_era_date(parts[1], year) if len(parts) > 1 else start
89+
if start is None or end is None:
6990
continue
91+
# If end is earlier than start, assume it wraps to next year
92+
if end < start:
93+
end = _parse_era_date(parts[1], year + 1) or end
94+
7095
commits = era.get("commits", 0)
7196
if isinstance(commits, str):
72-
import re
73-
m = re.search(r"(\d+)", commits)
97+
m = _re.search(r"(\d+)", commits)
7498
commits = int(m.group(1)) if m else 0
7599
eras.append(EraDef(
76100
id=era["id"],

archaeology/local_pipeline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def run_local_pipeline(
7474
"PIPELINE_REVIEW_DAYS": str(review_days),
7575
}
7676
)
77-
subprocess.run(cmd, cwd=pipeline_dir, env=env, check=True)
77+
subprocess.run(cmd, cwd=pipeline_dir, env=env, check=True, timeout=300)
7878

7979

8080
def read_local_pipeline_status(pipeline_dir: str | Path, repo_name: str) -> LocalPipelineStatus:

0 commit comments

Comments
 (0)