1"""ll-history-context: render a Historical Context block for an issue.

2

3 Queries .ll/history.db for user corrections and FTS5 matches relevant to an

4 issue ID and prints a ready-to-inject ``## Historical Context`` markdown block.

5 Returns empty output when the DB is missing, has no matches, or all rows are

6 stale.

7

8 Pass ``--project`` instead of an issue ID to print the project-wide context

9 digest that the session-start hook would inject (inspection / config-tuning

10 dry-run, ENH-1907).

11

12 Usage:

13 ll-history-context <issue_id> [--file <path>] [--db <path>]

14 ll-history-context --project [--db <path>]

15"""

16

17 from __future__ import annotations

18

19 import argparse

20 import hashlib

21 import logging

22 import re

23 import sys

24 from datetime import UTC, datetime, timedelta

25 from pathlib import Path

26

27 import yaml

28

29 from little_loops.cli.output import configure_output, use_color_enabled

30 from little_loops.config.core import resolve_config_path

31 from little_loops.history_reader import (

32 STALE_DAYS_DEFAULT,

33 SearchResult,

34 UserCorrection,

35 find_user_corrections,

36 issue_effort,

37 project_digest,

38 recent_file_events,

39 render_project_context,

40 search,

41)

42 from little_loops.logger import Logger

43 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context, connect

44

45 logger = logging.getLogger(__name__)

46

47_MAX_ROWS = 5

48

49

50 def _content_hash(content: str) -> str:

51 return hashlib.sha256(content.encode()).hexdigest()[:16]

52

53

54 def _render_learning_test_section(

55 targets: list[str],

56 *,

57 stale_after_days: int = 30,

58 base_dir: Path | None = None,

59) -> str | None:

60 """Build a ## Learning Test Evidence markdown table, or None if no records exist."""

61 from little_loops.learning_tests import check_learning_test

62 from little_loops.learning_tests.gate import is_record_stale

63

64 rows: list[str] = []

65 for target in targets:

66 record = check_learning_test(target, base_dir=base_dir)

67 if record is None:

68 continue

69 effective_status = "stale" if is_record_stale(record, stale_after_days) else record.status

70 passes = sum(1 for a in record.assertions if a.result == "pass")

71 fails = sum(1 for a in record.assertions if a.result == "fail")

72 untested = sum(1 for a in record.assertions if a.result == "untested")

73 rows.append(

74 f"| {target} | {effective_status} | {record.date} | {passes}/{fails}/{untested} |"

75 )

76

77 if not rows:

78 return None

79

80 lines = [

81 "## Learning Test Evidence",

82 "",

83 "| Target | Status | Date | Pass/Fail/Untested |",

84 "|--------|--------|------|--------------------|",

85 *rows,

86 ]

87 return "\n".join(lines)

88

89

90 def _get_issue_lt_targets(issue_id: str, cfg: object) -> list[str]:

91 """Read learning_tests_required from the issue file frontmatter."""

92 from little_loops.cli.issues.show import _resolve_issue_id

93

94 issue_path = _resolve_issue_id(cfg, issue_id) # type: ignore[arg-type]

95 if issue_path is None:

96 return []

97 try:

98 content = issue_path.read_text(encoding="utf-8")

99 except OSError:

100 return []

101 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)

102 if not fm_match:

103 return []

104 try:

105 fm = yaml.safe_load(fm_match.group(1)) or {}

106 except yaml.YAMLError:

107 return []

108 raw = fm.get("learning_tests_required")

109 if not raw:

110 return []

111 if isinstance(raw, list):

112 return [str(t) for t in raw]

113 if isinstance(raw, str):

114 return [raw]

115 return []

116

117

118 def _build_parser() -> argparse.ArgumentParser:

119 parser = argparse.ArgumentParser(

120 prog="ll-history-context",

121 description="Render a ## Historical Context block for an issue from .ll/history.db",

122 formatter_class=argparse.RawDescriptionHelpFormatter,

123 epilog="""

124 Examples:

125 %(prog)s ENH-1708 # Corrections matching the issue ID

126 %(prog)s ENH-1708 --file src/foo.py # Also include recent file events

127 %(prog)s ENH-1708 --db custom/history.db # Use a non-default database

128 %(prog)s --project # Print project-wide context digest

129""",

130 )

131 parser.add_argument(

132 "issue_id",

133 metavar="ISSUE_ID",

134 nargs="?",

135 default=None,

136 help="Issue ID to query (e.g. ENH-1708). Mutually exclusive with --project.",

137 )

138 parser.add_argument(

139 "--project",

140 action="store_true",

141 default=False,

142 help="Print the project-wide context digest (dry-run of session-start injection).",

143 )

144 parser.add_argument(

145 "--file",

146 metavar="PATH",

147 default=None,

148 help="Also include recent file events for this path",

149 )

150 parser.add_argument(

151 "--db",

152 type=Path,

153 default=DEFAULT_DB_PATH,

154 metavar="PATH",

155 help="Path to the session database (default: .ll/history.db)",

156 )

157 parser.add_argument(

158 "--effort",

159 action="store_true",

160 default=False,

161 help="Include effort/velocity context (session count and cycle time)",

162 )

163 parser.add_argument(

164 "--for-skill",

165 type=str,

166 default=None,

167 metavar="NAME",

168 help="Exit 0 with no output if NAME is not in history.planning_skills.",

169 )

170 return parser

171

172

173 def main_history_context() -> int:

174 """Entry point for ll-history-context command.

175

176 Returns:

177 0 on success (including empty output when no matches or DB absent), 1 on argument error.

178 """

179 with cli_event_context(DEFAULT_DB_PATH, "ll-history-context", sys.argv[1:]):

180 configure_output()

181 logger = Logger(use_color=use_color_enabled()) # noqa: F841

182

183 parser = _build_parser()

184 args = parser.parse_args()

185

186 # Mutual-exclusion guard: require exactly one of issue_id or --project.

187 if args.project and args.issue_id:

188 parser.error("--project and ISSUE_ID are mutually exclusive")

189 if not args.project and not args.issue_id:

190 parser.error("one of ISSUE_ID or --project is required")

191

192 # --project: print the project-wide digest dry-run and exit.

193 if args.project:

194 from little_loops.config.features import HistoryConfig

195

196 cwd = Path.cwd()

197 config_path = resolve_config_path(cwd)

198 if config_path is not None:

199 import json

200

201 try:

202 merged = json.loads(config_path.read_text(encoding="utf-8"))

203 except (OSError, json.JSONDecodeError):

204 merged = {}

205 else:

206 merged = {}

207

208 _hist = HistoryConfig.from_dict(merged.get("history", {}))

209 _sd = _hist.session_digest

210 _sections = _sd.sections if _sd.sections else None

211 digest = project_digest(args.db, days=_sd.days, sections=_sections)

212 block = render_project_context(digest, char_cap=_sd.char_cap, days=_sd.days)

213 if block:

214 print(block)

215 return 0

216

217 # --for-skill guard: exit 0 with no output if skill is not in history.planning_skills.

218 if args.for_skill is not None:

219 from little_loops.config import BRConfig

220

221 cfg = BRConfig(Path.cwd())

222 if args.for_skill not in cfg.history.planning_skills:

223 return 0

224

225 # --effort: print effort/velocity context and exit.

226 if args.effort:

227 from little_loops.config import BRConfig

228

229 cfg = BRConfig(Path.cwd())

230 fields = cfg.history.effort_fields

231 effort = issue_effort(args.issue_id, db=args.db)

232 if effort is None:

233 return 0

234 valid_fields = {"session_count", "cycle_time_days"}

235 print("## Effort Context")

236 print()

237 for f in fields:

238 if f not in valid_fields:

239 logger.warning(f"history_context: unknown effort field {f!r} — skipping")

240 continue

241 val = effort.get(f)

242 if val is None:

243 print(f"- {f}: n/a")

244 else:

245 print(f"- {f}: {val}")

246 return 0

247

248 cutoff = (datetime.now(UTC) - timedelta(days=STALE_DAYS_DEFAULT)).strftime(

249 "%Y-%m-%dT%H:%M:%SZ"

250 )

251

252 corrections: list[UserCorrection] = find_user_corrections(

253 topic=args.issue_id, limit=20, db=args.db

254 )

255

256 search_results: list[SearchResult] = search(

257 query=args.issue_id, kind="correction", limit=20, db=args.db

258 )

259 fresh_search = [r for r in search_results if r.ts >= cutoff]

260

261 file_contents: list[str] = []

262 if args.file:

263 for fe in recent_file_events(path=args.file, limit=5, db=args.db):

264 file_contents.append(f"file:{fe.path}:{fe.op}")

265

266 seen: set[str] = set()

267 rows: list[str] = []

268

269 for uc in corrections:

270 h = _content_hash(uc.content)

271 if h not in seen:

272 seen.add(h)

273 rows.append(uc.content)

274

275 for sr in fresh_search:

276 h = _content_hash(sr.content)

277 if h not in seen:

278 seen.add(h)

279 rows.append(sr.content)

280

281 for fc in file_contents:

282 h = _content_hash(fc)

283 if h not in seen:

284 seen.add(h)

285 rows.append(fc)

286

287 rows = rows[:_MAX_ROWS]

288

289 if not rows:

290 # Fallback: query issue_snapshots when no corrections/search rows found.

291 try:

292 conn = connect(args.db)

293 try:

294 snap = conn.execute(

295 "SELECT title, body FROM issue_snapshots"

296 " WHERE issue_id = ? ORDER BY ts DESC LIMIT 1",

297 (args.issue_id,),

298 ).fetchone()

299 finally:

300 conn.close()

301 if snap is not None:

302 body_text = (snap["body"] or "").strip()

303 if body_text:

304 rows.append(body_text[:512])

305 except Exception:

306 pass

307

308 # Build Learning Test Evidence section if feature is enabled.

309 lt_section: str | None = None

310 try:

311 from little_loops.config import BRConfig

312

313 cfg = BRConfig(Path.cwd())

314 if cfg.learning_tests.enabled:

315 targets = _get_issue_lt_targets(args.issue_id, cfg)

316 if targets:

317 lt_dir = Path.cwd() / ".ll" / "learning-tests"

318 lt_section = _render_learning_test_section(

319 targets,

320 stale_after_days=cfg.learning_tests.stale_after_days,

321 base_dir=lt_dir,

322 )

323 except Exception:

324 pass

325

326 # Build Prior Work (condensed) section if compaction is enabled (ENH-2231).

327 prior_work_section: str | None = None

328 _PRIOR_WORK_SECTION_BUDGET = 1000

329 _PRIOR_WORK_NODE_CHAR_CAP = 400

330 try:

331 from little_loops.config import BRConfig

332 from little_loops.history_reader import condensed_nodes_for_issue

333

334 _cfg = BRConfig(Path.cwd())

335 if _cfg.history.compaction.enabled:

336 _nodes = condensed_nodes_for_issue(

337 args.issue_id,

338 node_char_cap=_PRIOR_WORK_NODE_CHAR_CAP,

339 db=args.db,

340 )

341 if _nodes:

342 section_lines: list[str] = ["## Prior Work (condensed)", ""]

343 budget = _PRIOR_WORK_SECTION_BUDGET

344 for node in _nodes:

345 text = (node.content or "").strip()

346 provenance = (

347 f"*(session: {node.session_id or 'unknown'},"

348 f" ts_end: {node.ts_end or 'unknown'})*"

349 )

350 entry = f"{text}\n\n{provenance}"

351 if budget <= 0:

352 break

353 section_lines.append(entry)

354 section_lines.append("")

355 budget -= len(entry)

356 if len(section_lines) > 2:

357 prior_work_section = "\n".join(section_lines).rstrip()

358 except Exception:

359 pass

360

361 if not rows and lt_section is None and prior_work_section is None:

362 return 0

363

364 if rows:

365 print("## Historical Context")

366 print()

367 for row in rows:

368 print(f"- {row}")

369

370 if lt_section is not None:

371 if rows:

372 print()

373 print(lt_section)

374

375 if prior_work_section is not None:

376 if rows or lt_section is not None:

377 print()

378 print(prior_work_section)

379

380 return 0