From acb5a8f4ff17ffcb2dcb0243124ef5efd1f42877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ho=C3=A0i=20Nh=E1=BB=9B?= Date: Fri, 10 Jul 2026 23:51:03 +0700 Subject: [PATCH 1/4] feat(capture): auto-capture Claude sessions to traces + query coverage Adds Claude Code SessionEnd hook (scripts/hooks/claude-autocapture.py) that records token/duration/files/ticket per session, a 'query coverage' command, and a GCR insufficient-data guard (grey when <10 stories). --- crates/harness-cli/src/application.rs | 9 +- crates/harness-cli/src/domain.rs | 23 ++ crates/harness-cli/src/infrastructure.rs | 32 ++- crates/harness-cli/src/interface.rs | 89 ++++++- scripts/hooks/claude-autocapture.py | 284 +++++++++++++++++++++++ 5 files changed, 431 insertions(+), 6 deletions(-) create mode 100755 scripts/hooks/claude-autocapture.py diff --git a/crates/harness-cli/src/application.rs b/crates/harness-cli/src/application.rs index 14fd030..d5698ba 100644 --- a/crates/harness-cli/src/application.rs +++ b/crates/harness-cli/src/application.rs @@ -1,8 +1,9 @@ use std::path::PathBuf; use crate::domain::{ - AuditResult, BacklogFilter, BacklogRecord, BoolFlag, ContextScoreResult, CsvList, - DecisionRecord, FrictionRecord, GateLogRecord, GcrRecord, HarnessStats, ImprovementProposal, + AuditResult, BacklogFilter, BacklogRecord, BoolFlag, ContextScoreResult, CoverageReport, + CsvList, DecisionRecord, FrictionRecord, GateLogRecord, GcrRecord, HarnessStats, + ImprovementProposal, InputType, IntakeRecord, InterventionRecord, RiskLane, StoryExportRecord, StoryMatrixRecord, StoryVerifyAllResult, StoryVerifyStatus, ToolArgSpec, ToolEntry, TraceRecord, TraceScoreResult, }; @@ -328,6 +329,10 @@ impl HarnessService { pub fn query_gcr(&self) -> crate::infrastructure::Result> { self.repository.query_gcr() } + + pub fn query_coverage(&self) -> crate::infrastructure::Result { + self.repository.query_coverage() + } } #[derive(Debug, PartialEq, Eq)] diff --git a/crates/harness-cli/src/domain.rs b/crates/harness-cli/src/domain.rs index 9999ba7..61cc525 100644 --- a/crates/harness-cli/src/domain.rs +++ b/crates/harness-cli/src/domain.rs @@ -1204,6 +1204,29 @@ pub struct GcrRecord { pub rag: String, } +/// Adoption + instrumentation coverage snapshot for `query coverage`. +/// +/// Answers the question the raw stats can't: are sessions actually being +/// captured, and do the traces carry the token/duration telemetry that makes +/// GCR/ROI meaningful. Distinct from GCR, which scores *how well* a story +/// passed its gates — this scores *whether the durable layer is being fed at +/// all*. +#[derive(Debug, Clone)] +pub struct CoverageReport { + pub total_stories: i64, + pub stories_with_trace: i64, + pub verified_stories: i64, + pub total_traces: i64, + pub traces_with_token: i64, + pub traces_with_duration: i64, + pub total_tokens: i64, +} + +/// Minimum number of stories before a green/yellow/red verdict is +/// statistically meaningful. Below this, GCR and coverage report `grey +/// (insufficient data)` instead of a falsely reassuring colour. +pub const MIN_STORIES_FOR_RAG: usize = 10; + /// Full story projection used by `export matrix` and `export story`. /// Extends the proof columns from [`StoryMatrixRecord`] with the lane, /// last verify result, T4 verdict, and T4 notes — all needed for diff --git a/crates/harness-cli/src/infrastructure.rs b/crates/harness-cli/src/infrastructure.rs index 4147375..fbe8a33 100644 --- a/crates/harness-cli/src/infrastructure.rs +++ b/crates/harness-cli/src/infrastructure.rs @@ -16,7 +16,8 @@ use crate::application::{ use crate::domain::{ compiled_tool_registry, normalize_token, score_context, score_trace, validate_tool_description, AuditFinding, AuditResult, BacklogFilter, BacklogRecord, ContextScoreResult, - ContextScoreSource, DecisionRecord, FrictionRecord, GateLogRecord, GcrRecord, HarnessStats, + ContextScoreSource, CoverageReport, DecisionRecord, FrictionRecord, GateLogRecord, GcrRecord, + HarnessStats, ImprovementProposal, IntakeRecord, InterventionRecord, RiskLane, StoryExportRecord, StoryMatrixRecord, StoryVerifyAllItem, StoryVerifyAllResult, StoryVerifyStatus, ToolArgSpec, ToolEntry, TraceRecord, TraceScoreResult, TraceScoreSource, @@ -117,6 +118,8 @@ pub trait HarnessRepository { fn query_export_story(&self, id: &str) -> Result; /// Per-story gate-compliance rate, plus an overall-average summary row. fn query_gcr(&self) -> Result>; + /// Adoption + instrumentation coverage snapshot. + fn query_coverage(&self) -> Result; } #[derive(Debug)] @@ -1651,6 +1654,33 @@ ORDER BY gcr ASC;"; collect_rows(rows) } + + fn query_coverage(&self) -> Result { + let connection = self.open_existing()?; + let sql = " +SELECT + (SELECT COUNT(*) FROM story), + (SELECT COUNT(DISTINCT story_id) FROM trace WHERE story_id IS NOT NULL), + (SELECT COUNT(*) FROM story + WHERE unit_proof = 1 OR integration_proof = 1 OR e2e_proof = 1 + OR evidence IS NOT NULL OR last_verified_result = 'pass'), + (SELECT COUNT(*) FROM trace), + (SELECT COUNT(token_estimate) FROM trace), + (SELECT COUNT(duration_seconds) FROM trace), + (SELECT COALESCE(SUM(token_estimate), 0) FROM trace);"; + let report = connection.query_row(sql, [], |row| { + Ok(CoverageReport { + total_stories: row.get(0)?, + stories_with_trace: row.get(1)?, + verified_stories: row.get(2)?, + total_traces: row.get(3)?, + traces_with_token: row.get(4)?, + traces_with_duration: row.get(5)?, + total_tokens: row.get(6)?, + }) + })?; + Ok(report) + } } impl From for SqliteHarnessRepository { diff --git a/crates/harness-cli/src/interface.rs b/crates/harness-cli/src/interface.rs index 5e35aee..5659d12 100644 --- a/crates/harness-cli/src/interface.rs +++ b/crates/harness-cli/src/interface.rs @@ -15,8 +15,9 @@ use crate::application::{ use crate::domain::{ normalize_capability, parse_optional_integer, parse_tool_args, proof_display, validate_responsibility, validate_tool_kind, BacklogFilter, BacklogRecord, BoolFlag, - ContextScoreResult, CsvList, DecisionRecord, FrictionRecord, GateLogRecord, GcrRecord, - HarnessStats, ImprovementProposal, InputType, IntakeRecord, InterventionRecord, RiskLane, + ContextScoreResult, CoverageReport, CsvList, DecisionRecord, FrictionRecord, GateLogRecord, + GcrRecord, HarnessStats, ImprovementProposal, InputType, IntakeRecord, InterventionRecord, + MIN_STORIES_FOR_RAG, RiskLane, StoryExportRecord, StoryMatrixRecord, StoryVerifyAllResult, ToolEntry, TraceQualityTier, TraceRecord, TraceScoreResult, RISK_LANE_HELP, }; @@ -441,6 +442,8 @@ enum QueryView { GateLog, /// Per-story gate-compliance rate (GCR) from docs/p3-ac6-compliance-metric.md. Gcr, + /// Adoption + instrumentation coverage snapshot (are sessions being captured?). + Coverage, /// Run arbitrary SQL. Sql { query: Vec }, } @@ -929,6 +932,7 @@ pub fn run(cli: Cli) -> Result<(), InterfaceError> { QueryView::Stats => print_stats(&service.query_stats()?), QueryView::GateLog => print_gate_log(&service.query_gate_log()?), QueryView::Gcr => print_gcr(&service.query_gcr()?), + QueryView::Coverage => print_coverage(&service.query_coverage()?), QueryView::Sql { query } => { if query.is_empty() { return Err(InterfaceError::EmptySql); @@ -2553,7 +2557,13 @@ fn print_gcr(records: &[GcrRecord]) { let total_expected: i64 = records.iter().map(|record| record.gates_expected).sum(); if total_expected > 0 { let avg = total_recorded as f64 / total_expected as f64; - let avg_rag = if avg >= 0.85 { + // Data-sufficiency guard: below MIN_STORIES_FOR_RAG a colour is + // misleading (a single well-formed story reads "green" while the + // harness is effectively unused). Report grey instead. + let insufficient = records.len() < MIN_STORIES_FOR_RAG; + let avg_rag = if insufficient { + "grey (insufficient data)" + } else if avg >= 0.85 { "green" } else if avg >= 0.50 { "yellow" @@ -2568,6 +2578,79 @@ fn print_gcr(records: &[GcrRecord]) { avg * 100.0, avg_rag ); + if insufficient { + println!( + " ⚠ only {} story(ies) tracked (need ≥{} for a meaningful verdict).", + records.len(), + MIN_STORIES_FOR_RAG + ); + } + } +} + +fn print_coverage(report: &CoverageReport) { + fn pct(part: i64, whole: i64) -> f64 { + if whole == 0 { + 0.0 + } else { + part as f64 / whole as f64 * 100.0 + } + } + + let story_cov = pct(report.stories_with_trace, report.total_stories); + let verify_cov = pct(report.verified_stories, report.total_stories); + let token_cov = pct(report.traces_with_token, report.total_traces); + let dur_cov = pct(report.traces_with_duration, report.total_traces); + + print_table( + &["metric", "value", "coverage"], + &[ + vec![ + "stories with ≥1 trace".to_owned(), + format!("{}/{}", report.stories_with_trace, report.total_stories), + format!("{story_cov:.1}%"), + ], + vec![ + "stories verified".to_owned(), + format!("{}/{}", report.verified_stories, report.total_stories), + format!("{verify_cov:.1}%"), + ], + vec![ + "traces with token estimate".to_owned(), + format!("{}/{}", report.traces_with_token, report.total_traces), + format!("{token_cov:.1}%"), + ], + vec![ + "traces with duration".to_owned(), + format!("{}/{}", report.traces_with_duration, report.total_traces), + format!("{dur_cov:.1}%"), + ], + vec![ + "total tokens captured".to_owned(), + format!("{}", report.total_tokens), + "—".to_owned(), + ], + ], + ); + + println!(); + if (report.total_stories as usize) < MIN_STORIES_FOR_RAG { + println!( + "Adoption: grey (insufficient data) — {} story(ies), need ≥{}.", + report.total_stories, MIN_STORIES_FOR_RAG + ); + } else { + let rag = if token_cov >= 90.0 && story_cov >= 80.0 { + "green" + } else if token_cov >= 50.0 { + "yellow" + } else { + "red" + }; + println!( + "Adoption: {} — {:.0}% traces instrumented, {:.0}% stories traced.", + rag, token_cov, story_cov + ); } } diff --git a/scripts/hooks/claude-autocapture.py b/scripts/hooks/claude-autocapture.py new file mode 100755 index 0000000..e79f5bc --- /dev/null +++ b/scripts/hooks/claude-autocapture.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" +Janus auto-capture hook for Claude Code. + +Wired as a Claude Code `Stop` / `SessionEnd` hook. Reads the session transcript +that Claude Code passes on stdin ({"session_id","transcript_path","cwd",...}), +extracts token usage, wall-clock duration, changed files and any ticket key, then +records ONE trace into the Janus durable layer via the existing `harness-cli` +binary — no manual gate typing, no Rust rebuild. + +This closes the adoption gap: instead of relying on the agent to remember to run +`harness-cli trace`, every session that ends is captured automatically. + +Env: + HARNESS_DB path to harness.db (default ~/.harness/harness.db) + JANUS_CLI path to harness-cli (default: `harness-cli` on PATH) + JANUS_CAPTURE_DRYRUN=1 print the commands instead of executing them + JANUS_CAPTURE_ONLY_TICKET=1 only capture sessions that reference a ticket + +Exit code is always 0 (a capture failure must never break a Claude session). +""" +import json +import os +import re +import subprocess +import sys +from datetime import datetime + +HOME = os.path.expanduser("~") +DB = os.environ.get("HARNESS_DB", os.path.join(HOME, ".harness", "harness.db")) +CLI = os.environ.get("JANUS_CLI", "harness-cli") +DRYRUN = os.environ.get("JANUS_CAPTURE_DRYRUN") == "1" +ONLY_TICKET = os.environ.get("JANUS_CAPTURE_ONLY_TICKET") == "1" + +TICKET_RE = re.compile(r"\b(WIN|WPD)-\d+\b", re.I) +EDIT_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"} + +# Gaps between consecutive messages longer than this are treated as idle +# (session left open) and capped, so `duration` approximates *active* time +# rather than raw wall-clock span. 5 minutes matches the prompt-cache TTL — a +# natural boundary for "still the same working burst". +IDLE_CAP_SECONDS = 300 + +# Content injected by the harness (not typed by the user). Ticket/summary +# extraction must ignore these or it grabs context noise (e.g. a WIN- ref that +# only appears in the loaded wiki index, not in the user's actual request). +INJECTED_MARKERS = ( + "", "", + "Caveat: The messages below", "DO NOT respond", "[PROJECT MEMORY]", + "session-restore", "stdout", "[LLM Wiki", +) + + +def is_injected(text): + return any(m in text for m in INJECTED_MARKERS) + + +def user_text(obj): + """Return clean user-typed text for a transcript entry, or '' if this turn + is a tool result / injected content / not a user turn.""" + if obj.get("type") != "user": + return "" + msg = obj.get("message") or {} + content = msg.get("content") + parts = [] + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "text": + parts.append(b.get("text", "")) + # tool_result blocks are not user-typed → ignore + text = " ".join(parts).strip() + if not text or is_injected(text): + return "" + return text + + +def log(msg): + sys.stderr.write(f"[janus-autocapture] {msg}\n") + + +def parse_ts(s): + if not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except Exception: + return None + + +def scan_transcript(path): + """Single pass; only json-parse lines that carry signal (keeps huge + base64 tool-result lines cheap).""" + tokens = 0 + files_changed = set() + ticket = None + first_ts = last_ts = prev_ts = None + active_seconds = 0 + first_user_text = None + n_msgs = 0 + errors = 0 + + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + for line in fh: + n_msgs += 1 + has_usage = '"usage"' in line + has_tooluse = '"tool_use"' in line or '"name":"Edit"' in line or '"name":"Write"' in line + has_ticket = ("WIN-" in line.upper()) or ("WPD-" in line.upper()) + has_ts = '"timestamp"' in line + if not (has_usage or has_tooluse or has_ticket or has_ts): + continue + try: + obj = json.loads(line) + except Exception: + continue + + ts = parse_ts(obj.get("timestamp")) + if ts: + if first_ts is None: + first_ts = ts + if prev_ts is not None: + gap = (ts - prev_ts).total_seconds() + if gap > 0: + active_seconds += min(gap, IDLE_CAP_SECONDS) + prev_ts = ts + last_ts = ts + + msg = obj.get("message") or {} + usage = msg.get("usage") if isinstance(msg, dict) else None + if isinstance(usage, dict): + # Exclude cache_read: it re-counts the same context on every turn + # and inflates a session to tens of millions. Real spend proxy = + # input + output + cache_creation. + tokens += ( + usage.get("input_tokens", 0) + + usage.get("output_tokens", 0) + + usage.get("cache_creation_input_tokens", 0) + ) + + content = msg.get("content") if isinstance(msg, dict) else None + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "tool_use" and block.get("name") in EDIT_TOOLS: + fp = (block.get("input") or {}).get("file_path") + if fp: + files_changed.add(fp) + if btype == "tool_result" and block.get("is_error"): + errors += 1 + + # ticket + summary ONLY from genuine user-typed text + utext = user_text(obj) + if utext: + if first_user_text is None: + first_user_text = utext[:400] + if ticket is None: + m = TICKET_RE.search(utext) + if m: + ticket = m.group(0).upper() + + wall_clock = None + if first_ts and last_ts: + wall_clock = int((last_ts - first_ts).total_seconds()) + duration = int(active_seconds) if first_ts else None + + return { + "tokens": tokens, + "files_changed": sorted(files_changed), + "ticket": ticket, + "duration": duration, # active time (idle gaps capped) + "wall_clock": wall_clock, # raw first→last span + "summary": (first_user_text or "").strip(), + "errors": errors, + "n_lines": n_msgs, + } + + +def already_captured(session_id): + try: + out = subprocess.run( + ["sqlite3", DB, + f"SELECT 1 FROM trace WHERE notes LIKE '%{session_id}%' LIMIT 1;"], + capture_output=True, text=True, timeout=10, + ) + return out.stdout.strip() == "1" + except Exception: + return False + + +def run(cmd): + if DRYRUN: + print("DRYRUN:", " ".join(_q(c) for c in cmd)) + return 0 + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if r.returncode != 0: + log(f"cmd failed ({r.returncode}): {r.stderr.strip()[:200]}") + return r.returncode + except Exception as e: + log(f"cmd error: {e}") + return 1 + + +def _q(s): + return f'"{s}"' if " " in str(s) else str(s) + + +def main(): + raw = sys.stdin.read() if not sys.stdin.isatty() else "{}" + try: + hook = json.loads(raw) if raw.strip() else {} + except Exception: + hook = {} + + transcript = hook.get("transcript_path") or (sys.argv[1] if len(sys.argv) > 1 else None) + if not transcript or not os.path.exists(transcript): + log(f"no transcript ({transcript}); nothing to capture") + return 0 + # For backfill (no hook stdin), derive a stable session id from the + # transcript filename (Claude Code names files by session UUID) so + # idempotency holds per-file. + session_id = hook.get("session_id") or os.path.splitext(os.path.basename(transcript))[0] + + data = scan_transcript(transcript) + + # only capture sessions that actually did something + if not data["files_changed"] and not data["ticket"]: + log("session touched no files and referenced no ticket; skipping") + return 0 + if ONLY_TICKET and not data["ticket"]: + log("no ticket and ONLY_TICKET set; skipping") + return 0 + + if not DRYRUN and already_captured(session_id): + log(f"session {session_id} already captured; skipping") + return 0 + + ticket = data["ticket"] + summary = data["summary"] or f"Claude session {session_id[:8]}" + summary = re.sub(r"\s+", " ", summary)[:120] + notes = (f"auto-captured; session_id={session_id}; lines={data['n_lines']}; " + f"wall_clock_s={data.get('wall_clock')}") + + # ensure a story row exists for the ticket (idempotent-ish: add is safe to + # attempt; harness-cli rejects dup PK, which we tolerate) + if ticket: + run([CLI, "story", "add", "--id", ticket, + "--title", summary, "--lane", "normal", + "--notes", "auto-captured story stub (lane defaulted; confirm)"]) + + trace_cmd = [ + CLI, "trace", + "--summary", summary, + "--agent", "claude", + "--outcome", "completed" if data["errors"] == 0 else "partial", + "--notes", notes, + ] + if ticket: + trace_cmd += ["--story", ticket] + if data["tokens"]: + trace_cmd += ["--tokens", str(data["tokens"])] + if data["duration"] is not None: + trace_cmd += ["--duration", str(data["duration"])] + if data["files_changed"]: + trace_cmd += ["--changed", ",".join(data["files_changed"])] + if data["errors"]: + trace_cmd += ["--errors", f"{data['errors']} tool errors observed"] + + run(trace_cmd) + log(f"captured session {session_id[:8]} " + f"ticket={ticket} tokens={data['tokens']} " + f"dur={data['duration']}s files={len(data['files_changed'])}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: # never break a session + log(f"fatal (swallowed): {e}") + sys.exit(0) From 84049be42ddc0de45aba9351d71f2d7de76a8e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ho=C3=A0i=20Nh=E1=BB=9B?= Date: Sat, 11 Jul 2026 14:03:08 +0700 Subject: [PATCH 2/4] chore: ignore python bytecode from capture hook --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6991af8..9429c41 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ scripts/__pycache__/ scripts/recall_health.json .omc/ .omc/ + +# Python bytecode from capture hook. +__pycache__/ +*.pyc From c8981406f3d79108a875c1000c3e3ed9629dcdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ho=C3=A0i=20Nh=E1=BB=9B?= Date: Sat, 11 Jul 2026 16:20:52 +0700 Subject: [PATCH 3/4] feat(capture): tag workspace (personal/geargames) in trace notes --- scripts/hooks/claude-autocapture.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/hooks/claude-autocapture.py b/scripts/hooks/claude-autocapture.py index e79f5bc..4459583 100755 --- a/scripts/hooks/claude-autocapture.py +++ b/scripts/hooks/claude-autocapture.py @@ -208,6 +208,18 @@ def _q(s): return f'"{s}"' if " " in str(s) else str(s) +def detect_workspace(hook, transcript): + """Classify the session's workspace so a single shared Janus DB stays + analysable per-workspace. Live sessions carry `cwd`; backfill infers from + the transcript's project-dir path (Claude encodes cwd with dashes).""" + hay = f"{hook.get('cwd', '')} {transcript or ''}" + if "Documents/personal" in hay or "Documents-personal" in hay: + return "personal" + if "Documents/geargames" in hay or "Documents-geargames" in hay: + return "geargames" + return "other" + + def main(): raw = sys.stdin.read() if not sys.stdin.isatty() else "{}" try: @@ -239,10 +251,11 @@ def main(): return 0 ticket = data["ticket"] + workspace = detect_workspace(hook, transcript) summary = data["summary"] or f"Claude session {session_id[:8]}" summary = re.sub(r"\s+", " ", summary)[:120] - notes = (f"auto-captured; session_id={session_id}; lines={data['n_lines']}; " - f"wall_clock_s={data.get('wall_clock')}") + notes = (f"auto-captured; workspace={workspace}; session_id={session_id}; " + f"lines={data['n_lines']}; wall_clock_s={data.get('wall_clock')}") # ensure a story row exists for the ticket (idempotent-ish: add is safe to # attempt; harness-cli rejects dup PK, which we tolerate) @@ -270,7 +283,7 @@ def main(): trace_cmd += ["--errors", f"{data['errors']} tool errors observed"] run(trace_cmd) - log(f"captured session {session_id[:8]} " + log(f"captured session {session_id[:8]} ws={workspace} " f"ticket={ticket} tokens={data['tokens']} " f"dur={data['duration']}s files={len(data['files_changed'])}") return 0 From 8ae8cf1961ee5c8520fcf2108935517cf5084d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ho=C3=A0i=20Nh=E1=BB=9B?= Date: Sat, 11 Jul 2026 16:43:49 +0700 Subject: [PATCH 4/4] fix(review): address PR feedback Built-in sqlite3 (parameterized, no CLI dep) for already_captured + new story_exists guard; defensive non-dict message check; drop per-line upper(); CAST SUM to INTEGER for rusqlite type safety. --- crates/harness-cli/src/infrastructure.rs | 2 +- scripts/hooks/claude-autocapture.py | 36 ++++++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/harness-cli/src/infrastructure.rs b/crates/harness-cli/src/infrastructure.rs index fbe8a33..2911d07 100644 --- a/crates/harness-cli/src/infrastructure.rs +++ b/crates/harness-cli/src/infrastructure.rs @@ -1667,7 +1667,7 @@ SELECT (SELECT COUNT(*) FROM trace), (SELECT COUNT(token_estimate) FROM trace), (SELECT COUNT(duration_seconds) FROM trace), - (SELECT COALESCE(SUM(token_estimate), 0) FROM trace);"; + (SELECT CAST(COALESCE(SUM(token_estimate), 0) AS INTEGER) FROM trace);"; let report = connection.query_row(sql, [], |row| { Ok(CoverageReport { total_stories: row.get(0)?, diff --git a/scripts/hooks/claude-autocapture.py b/scripts/hooks/claude-autocapture.py index 4459583..c9864d5 100755 --- a/scripts/hooks/claude-autocapture.py +++ b/scripts/hooks/claude-autocapture.py @@ -22,6 +22,7 @@ import json import os import re +import sqlite3 import subprocess import sys from datetime import datetime @@ -60,7 +61,9 @@ def user_text(obj): is a tool result / injected content / not a user turn.""" if obj.get("type") != "user": return "" - msg = obj.get("message") or {} + msg = obj.get("message") + if not isinstance(msg, dict): + return "" content = msg.get("content") parts = [] if isinstance(content, str): @@ -106,7 +109,8 @@ def scan_transcript(path): n_msgs += 1 has_usage = '"usage"' in line has_tooluse = '"tool_use"' in line or '"name":"Edit"' in line or '"name":"Write"' in line - has_ticket = ("WIN-" in line.upper()) or ("WPD-" in line.upper()) + # avoid line.upper() — it copies the whole (possibly huge) line + has_ticket = any(p in line for p in ("WIN-", "win-", "WPD-", "wpd-")) has_ts = '"timestamp"' in line if not (has_usage or has_tooluse or has_ticket or has_ts): continue @@ -178,18 +182,26 @@ def scan_transcript(path): } -def already_captured(session_id): +def _db_query_one(sql, params): + """Read helper via Python's built-in sqlite3 — no external `sqlite3` CLI + dependency, parameterized (no injection), no noisy stderr.""" try: - out = subprocess.run( - ["sqlite3", DB, - f"SELECT 1 FROM trace WHERE notes LIKE '%{session_id}%' LIMIT 1;"], - capture_output=True, text=True, timeout=10, - ) - return out.stdout.strip() == "1" + with sqlite3.connect(DB) as conn: + return conn.execute(sql, params).fetchone() is not None except Exception: return False +def already_captured(session_id): + return _db_query_one( + "SELECT 1 FROM trace WHERE notes LIKE ? LIMIT 1;", (f"%{session_id}%",) + ) + + +def story_exists(story_id): + return _db_query_one("SELECT 1 FROM story WHERE id = ? LIMIT 1;", (story_id,)) + + def run(cmd): if DRYRUN: print("DRYRUN:", " ".join(_q(c) for c in cmd)) @@ -257,9 +269,9 @@ def main(): notes = (f"auto-captured; workspace={workspace}; session_id={session_id}; " f"lines={data['n_lines']}; wall_clock_s={data.get('wall_clock')}") - # ensure a story row exists for the ticket (idempotent-ish: add is safe to - # attempt; harness-cli rejects dup PK, which we tolerate) - if ticket: + # ensure a story row exists for the ticket — check first to avoid noisy + # dup-primary-key errors on stderr from harness-cli + if ticket and not story_exists(ticket): run([CLI, "story", "add", "--id", ticket, "--title", summary, "--lane", "normal", "--notes", "auto-captured story stub (lane defaulted; confirm)"])