Coverage for little_loops / cli / issues / decisions.py: 0%

413 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -0500

1"""ll-issues decisions: Manage rules, decisions, and exceptions log.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from datetime import UTC, datetime 

8from typing import TYPE_CHECKING 

9 

10if TYPE_CHECKING: 

11 from little_loops.config import BRConfig 

12 

13 

14def add_decisions_parser(subs: argparse._SubParsersAction) -> argparse.ArgumentParser: 

15 """Register the decisions subparser with all sub-sub-commands on *subs*.""" 

16 from little_loops.cli_args import add_config_arg 

17 

18 p = subs.add_parser( 

19 "decisions", 

20 help="Manage rules, decisions, and exceptions log", 

21 ) 

22 p.set_defaults(command="decisions") 

23 p.set_defaults(_decisions_parser=p) 

24 

25 add_config_arg(p) 

26 subsubs = p.add_subparsers(dest="subcommand") 

27 

28 # -- list -- 

29 list_p = subsubs.add_parser("list", help="List decisions log entries") 

30 list_p.add_argument( 

31 "--type", 

32 choices=["rule", "decision", "exception", "coupling"], 

33 default=None, 

34 help="Filter by entry type", 

35 ) 

36 list_p.add_argument("--category", default=None, help="Filter by category") 

37 list_p.add_argument("--label", default=None, help="Filter by label") 

38 list_p.add_argument( 

39 "--archetype", 

40 default=None, 

41 help="Filter coupling entries by archetype label (e.g. add-cli-command)", 

42 ) 

43 list_p.add_argument( 

44 "--no-outcome", 

45 action="store_true", 

46 help="Show only DecisionEntry records with no recorded outcome", 

47 ) 

48 list_p.add_argument( 

49 "--before", 

50 default=None, 

51 metavar="ISO-8601", 

52 help="Show entries with timestamp before this date", 

53 ) 

54 list_p.add_argument( 

55 "--scope", 

56 default=None, 

57 help="Filter DecisionEntry records by scope (e.g. 'issue', 'project')", 

58 ) 

59 list_p.add_argument( 

60 "--format", 

61 "-f", 

62 choices=["text", "json"], 

63 default="text", 

64 help="Output format (default: text)", 

65 ) 

66 list_p.add_argument( 

67 "--active-only", 

68 action="store_true", 

69 help="Exclude entries superseded by a newer entry", 

70 ) 

71 add_config_arg(list_p) 

72 

73 # -- add -- 

74 add_p = subsubs.add_parser("add", help="Add a new entry to the decisions log") 

75 add_p.add_argument( 

76 "--type", 

77 required=True, 

78 choices=["rule", "decision", "exception", "coupling"], 

79 help="Entry type", 

80 ) 

81 add_p.add_argument("--category", required=True, help="Category (e.g. 'architecture')") 

82 add_p.add_argument( 

83 "--rule", 

84 default=None, 

85 help="Rule or decision text (required for type=rule or type=decision)", 

86 ) 

87 add_p.add_argument( 

88 "--rationale", 

89 required=True, 

90 help="Why this rule/decision/exception applies", 

91 ) 

92 add_p.add_argument("--issue", default=None, help="Related issue ID (e.g. FEAT-1894)") 

93 add_p.add_argument("--label", default=None, help="Comma-separated labels") 

94 add_p.add_argument( 

95 "--enforcement", 

96 choices=["required", "advisory"], 

97 default="advisory", 

98 help="Enforcement level for type=rule (default: advisory)", 

99 ) 

100 add_p.add_argument( 

101 "--rule-ref", 

102 default=None, 

103 dest="rule_ref", 

104 metavar="RULE-ID", 

105 help="Reference to the rule being excepted (required for type=exception)", 

106 ) 

107 add_p.add_argument( 

108 "--alternatives-rejected", 

109 default=None, 

110 dest="alternatives_rejected", 

111 help="Alternatives considered and rejected (for type=decision or type=exception)", 

112 ) 

113 add_p.add_argument( 

114 "--supersedes", 

115 default=None, 

116 metavar="ENTRY-ID", 

117 help="ID of the entry this one supersedes (for type=rule)", 

118 ) 

119 add_p.add_argument( 

120 "--scope", 

121 default="issue", 

122 help="Decision scope: 'issue' (default) or 'project' (for type=decision)", 

123 ) 

124 add_p.add_argument( 

125 "--id", 

126 default=None, 

127 dest="entry_id", 

128 metavar="ENTRY-ID", 

129 help="Explicit entry ID (auto-generated if omitted)", 

130 ) 

131 # Coupling-specific flags (only relevant when --type=coupling) 

132 add_p.add_argument( 

133 "--if-changed", 

134 default=None, 

135 dest="if_changed", 

136 metavar="GLOB", 

137 help="Glob pattern matching files being modified (required for type=coupling)", 

138 ) 

139 add_p.add_argument( 

140 "--then-check", 

141 default=None, 

142 dest="then_check", 

143 metavar="PATHS", 

144 help="Comma-separated file/pattern list to audit for wiring gaps (required for type=coupling)", 

145 ) 

146 add_p.add_argument( 

147 "--tier", 

148 choices=["hard", "soft", "fyi"], 

149 default="soft", 

150 help="Coupling tier: hard (must change together), soft (should update), fyi (informational)", 

151 ) 

152 add_p.add_argument( 

153 "--archetype", 

154 default=None, 

155 dest="archetype", 

156 help="Named bundle label grouping related coupling rules (e.g. add-cli-command)", 

157 ) 

158 add_config_arg(add_p) 

159 

160 # -- outcome -- 

161 outcome_p = subsubs.add_parser("outcome", help="Record the outcome of a decision entry") 

162 outcome_p.add_argument("id", help="Entry ID to record outcome for") 

163 outcome_p.add_argument( 

164 "--result", 

165 required=True, 

166 choices=["worked", "did_not_work", "mixed", "reversed"], 

167 help="Outcome result", 

168 ) 

169 outcome_p.add_argument("--notes", default=None, help="Free-text notes about the outcome") 

170 outcome_p.add_argument( 

171 "--measured-at", 

172 default=None, 

173 dest="measured_at", 

174 metavar="ISO-8601", 

175 help="When the outcome was measured (default: now)", 

176 ) 

177 outcome_p.add_argument( 

178 "--force", 

179 action="store_true", 

180 help="Overwrite an existing outcome", 

181 ) 

182 add_config_arg(outcome_p) 

183 

184 # -- generate (stub) -- 

185 gen_p = subsubs.add_parser( 

186 "generate", 

187 help="Generate decisions log entries from completed issues", 

188 ) 

189 gen_p.add_argument( 

190 "--from", 

191 dest="source", 

192 default="completed", 

193 choices=["completed"], 

194 help="Source to generate from (default: completed)", 

195 ) 

196 add_config_arg(gen_p) 

197 

198 # -- sync (stub) -- 

199 sync_p = subsubs.add_parser("sync", help="Sync active rules to .ll/ll.local.md") 

200 add_config_arg(sync_p) 

201 

202 # -- suggest-rules -- 

203 suggest_p = subsubs.add_parser( 

204 "suggest-rules", 

205 help="Suggest decision entries that are candidates for promotion to rules", 

206 ) 

207 add_config_arg(suggest_p) 

208 

209 # -- extract-from-completed -- 

210 extract_p = subsubs.add_parser( 

211 "extract-from-completed", 

212 help="Extract decisions and rules from completed issues via LLM", 

213 ) 

214 extract_p.add_argument( 

215 "--since", 

216 metavar="YYYY-MM-DD", 

217 default=None, 

218 help="Only process issues completed on or after this date", 

219 ) 

220 extract_p.add_argument( 

221 "--issue", 

222 metavar="ID", 

223 default=None, 

224 help="Only extract from this specific issue ID (e.g. ENH-2151)", 

225 ) 

226 extract_p.add_argument( 

227 "--dry-run", 

228 action="store_true", 

229 default=False, 

230 help="Print candidates without writing to decisions.yaml", 

231 ) 

232 extract_p.add_argument( 

233 "--min-confidence", 

234 type=float, 

235 default=0.7, 

236 dest="min_confidence", 

237 metavar="FLOAT", 

238 help="Minimum LLM confidence to accept a candidate (default: 0.7)", 

239 ) 

240 add_config_arg(extract_p) 

241 

242 # -- promote -- 

243 promote_p = subsubs.add_parser( 

244 "promote", 

245 help="Convert a decision entry to a rule", 

246 ) 

247 promote_p.add_argument("entry_id", help="ID of the decision entry to promote") 

248 promote_p.add_argument( 

249 "--enforcement", 

250 choices=["required", "advisory"], 

251 default="required", 

252 help="Enforcement level for the resulting rule (default: required)", 

253 ) 

254 add_config_arg(promote_p) 

255 

256 return p 

257 

258 

259def cmd_decisions(config: BRConfig, args: argparse.Namespace) -> int: 

260 """Dispatch decisions sub-sub-commands; returns 0 on success, 1 on error.""" 

261 from pathlib import Path 

262 

263 from little_loops.decisions import ( 

264 CouplingEntry, 

265 DecisionEntry, 

266 ExceptionEntry, 

267 RuleEntry, 

268 add_entry, 

269 list_entries, 

270 resolve_active, 

271 set_outcome, 

272 ) 

273 

274 if not getattr(args, "subcommand", None): 

275 args._decisions_parser.print_help() 

276 return 1 

277 

278 # Resolve the decisions log path from config 

279 path = Path(config.project_root) / config.decisions.log_path 

280 

281 sub = args.subcommand 

282 

283 if sub == "list": 

284 return _cmd_list(args, path, list_entries, resolve_active) 

285 

286 if sub == "add": 

287 return _cmd_add( 

288 args, 

289 path, 

290 RuleEntry, 

291 DecisionEntry, 

292 ExceptionEntry, 

293 CouplingEntry, 

294 add_entry, 

295 list_entries, 

296 ) 

297 

298 if sub == "outcome": 

299 return _cmd_outcome(args, path, set_outcome) 

300 

301 if sub == "generate": 

302 from little_loops.decisions import generate_from_completed 

303 

304 count = generate_from_completed(config) 

305 print( 

306 f"Generated {count} decision entr{'y' if count == 1 else 'ies'} from completed issues." 

307 ) 

308 return 0 

309 

310 if sub == "sync": 

311 return _cmd_sync(path) 

312 

313 if sub == "suggest-rules": 

314 return _cmd_suggest_rules(path) 

315 

316 if sub == "extract-from-completed": 

317 return _cmd_extract_from_completed(config, args, path) 

318 

319 if sub == "promote": 

320 from little_loops.decisions import DecisionEntry, RuleEntry, load_decisions, save_decisions 

321 

322 return _cmd_promote(args, path, load_decisions, save_decisions, RuleEntry, DecisionEntry) 

323 

324 print(f"Unknown subcommand: {sub!r}", file=sys.stderr) 

325 return 1 

326 

327 

328def _cmd_list(args, path, list_entries, resolve_active) -> int: 

329 import json 

330 

331 entries = list_entries( 

332 path=path, 

333 type=getattr(args, "type", None), 

334 category=getattr(args, "category", None), 

335 label=getattr(args, "label", None), 

336 ) 

337 

338 # Post-filters 

339 if getattr(args, "no_outcome", False): 

340 from little_loops.decisions import DecisionEntry 

341 

342 entries = [e for e in entries if isinstance(e, DecisionEntry) and e.outcome is None] 

343 

344 if getattr(args, "before", None): 

345 entries = [e for e in entries if e.timestamp < args.before] 

346 

347 if getattr(args, "scope", None): 

348 from little_loops.decisions import DecisionEntry 

349 

350 entries = [e for e in entries if isinstance(e, DecisionEntry) and e.scope == args.scope] 

351 

352 if getattr(args, "active_only", False): 

353 entries = resolve_active(entries) 

354 

355 if getattr(args, "archetype", None): 

356 from little_loops.decisions import CouplingEntry as _CE 

357 

358 entries = [e for e in entries if isinstance(e, _CE) and e.archetype == args.archetype] 

359 

360 fmt = getattr(args, "format", "text") or "text" 

361 if fmt == "json": 

362 print(json.dumps([e.to_dict() for e in entries], indent=2)) 

363 return 0 

364 

365 if not entries: 

366 print("(no entries)") 

367 return 0 

368 

369 for entry in entries: 

370 _print_entry(entry) 

371 return 0 

372 

373 

374def _print_entry(entry) -> None: 

375 from little_loops.decisions import CouplingEntry, DecisionEntry, ExceptionEntry, RuleEntry 

376 

377 label_str = ", ".join(entry.labels) if entry.labels else "" 

378 print( 

379 f"{entry.id} [{entry.type}] {entry.category}" + (f" ({label_str})" if label_str else "") 

380 ) 

381 if isinstance(entry, (RuleEntry, DecisionEntry)): 

382 print(f" rule: {entry.rule}") 

383 elif isinstance(entry, ExceptionEntry): 

384 print(f" rule_ref: {entry.rule_ref}") 

385 elif isinstance(entry, CouplingEntry): 

386 print(f" if_changed: {entry.if_changed}") 

387 print(f" then_check: {', '.join(entry.then_check)}") 

388 print(f" tier: {entry.tier}") 

389 if entry.archetype: 

390 print(f" archetype: {entry.archetype}") 

391 if entry.rationale: 

392 print(f" rationale: {entry.rationale}") 

393 if isinstance(entry, DecisionEntry) and entry.outcome: 

394 print(f" outcome: {entry.outcome.result} @ {entry.outcome.measured_at}") 

395 

396 

397def _cmd_add( 

398 args, path, RuleEntry, DecisionEntry, ExceptionEntry, CouplingEntry, add_entry, list_entries 

399) -> int: 

400 entry_type = args.type 

401 

402 # Validate type-specific required fields 

403 if entry_type == "rule" and not getattr(args, "rule", None): 

404 print("Error: --rule is required for type 'rule'", file=sys.stderr) 

405 return 1 

406 if entry_type == "decision" and not getattr(args, "rule", None): 

407 print("Error: --rule is required for type 'decision' (the decision text)", file=sys.stderr) 

408 return 1 

409 if entry_type == "exception" and not getattr(args, "rule_ref", None): 

410 print("Error: --rule-ref is required for type 'exception'", file=sys.stderr) 

411 return 1 

412 if entry_type == "coupling": 

413 if not getattr(args, "if_changed", None): 

414 print("Error: --if-changed is required for type 'coupling'", file=sys.stderr) 

415 return 1 

416 if not getattr(args, "then_check", None): 

417 print("Error: --then-check is required for type 'coupling'", file=sys.stderr) 

418 return 1 

419 

420 # Generate an ID if not provided 

421 entry_id = getattr(args, "entry_id", None) 

422 if not entry_id: 

423 existing = list_entries(path=path, type=entry_type) 

424 prefix = { 

425 "rule": args.category.upper() if args.category else "RULE", 

426 "decision": args.category.upper() if args.category else "DEC", 

427 "exception": args.category.upper() + "-EX" if args.category else "EX", 

428 "coupling": "COUPLING", 

429 }[entry_type] 

430 entry_id = f"{prefix}-{len(existing) + 1:03d}" 

431 

432 timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 

433 labels = [lbl.strip() for lbl in args.label.split(",")] if getattr(args, "label", None) else [] 

434 

435 if entry_type == "rule": 

436 entry = RuleEntry( 

437 id=entry_id, 

438 timestamp=timestamp, 

439 category=args.category, 

440 labels=labels, 

441 rationale=args.rationale, 

442 rule=args.rule, 

443 enforcement=getattr(args, "enforcement", "advisory"), 

444 supersedes=getattr(args, "supersedes", None), 

445 issue=getattr(args, "issue", None), 

446 ) 

447 elif entry_type == "decision": 

448 entry = DecisionEntry( 

449 id=entry_id, 

450 timestamp=timestamp, 

451 category=args.category, 

452 labels=labels, 

453 rationale=args.rationale, 

454 rule=args.rule, 

455 alternatives_rejected=getattr(args, "alternatives_rejected", None), 

456 issue=getattr(args, "issue", None), 

457 scope=getattr(args, "scope", "issue"), 

458 ) 

459 elif entry_type == "coupling": 

460 then_check = [t.strip() for t in args.then_check.split(",")] 

461 entry = CouplingEntry( 

462 id=entry_id, 

463 timestamp=timestamp, 

464 category=args.category, 

465 labels=labels, 

466 rationale=args.rationale, 

467 if_changed=args.if_changed, 

468 then_check=then_check, 

469 tier=getattr(args, "tier", "soft"), 

470 archetype=getattr(args, "archetype", None), 

471 enforcement=getattr(args, "enforcement", "advisory"), 

472 issue=getattr(args, "issue", None), 

473 ) 

474 else: # exception 

475 entry = ExceptionEntry( 

476 id=entry_id, 

477 timestamp=timestamp, 

478 category=args.category, 

479 labels=labels, 

480 rationale=args.rationale, 

481 rule_ref=args.rule_ref, 

482 issue=getattr(args, "issue", "") or "", 

483 alternatives_rejected=getattr(args, "alternatives_rejected", None), 

484 ) 

485 

486 add_entry(entry, path=path) 

487 print(f"Added {entry_type} entry: {entry_id}") 

488 return 0 

489 

490 

491def _cmd_outcome(args, path, set_outcome) -> int: 

492 measured_at = getattr(args, "measured_at", None) or datetime.now(UTC).strftime( 

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

494 ) 

495 try: 

496 set_outcome( 

497 args.id, 

498 args.result, 

499 measured_at, 

500 notes=getattr(args, "notes", None), 

501 path=path, 

502 force=getattr(args, "force", False), 

503 ) 

504 except KeyError: 

505 print(f"Error: entry {args.id!r} not found", file=sys.stderr) 

506 return 1 

507 except TypeError as exc: 

508 print(f"Error: {exc}", file=sys.stderr) 

509 return 1 

510 except ValueError as exc: 

511 print(f"Error: {exc} (use --force to overwrite)", file=sys.stderr) 

512 return 1 

513 print(f"Recorded outcome for {args.id}: {args.result}") 

514 return 0 

515 

516 

517def _cmd_sync(path) -> int: 

518 try: 

519 from little_loops.decisions_sync import sync_to_local_md 

520 

521 sync_to_local_md(path=path) 

522 except ImportError: 

523 print("sync not yet available (requires FEAT-1895)", file=sys.stderr) 

524 return 1 

525 return 0 

526 

527 

528def _cmd_suggest_rules(path) -> int: 

529 """Analyze decision entries and suggest candidates for promotion to rules.""" 

530 import re 

531 from collections import defaultdict 

532 

533 from little_loops.decisions import DecisionEntry, list_entries 

534 

535 all_entries = list_entries(path=path) 

536 decisions = [e for e in all_entries if isinstance(e, DecisionEntry)] 

537 

538 if len(decisions) < 3: 

539 count = len(decisions) 

540 noun = "entry" if count == 1 else "entries" 

541 print(f"(only {count} decision {noun} found; need at least 3 to suggest rule candidates)") 

542 return 1 

543 

544 # Heuristic: rule texts that are one-off choices rather than general constraints 

545 _one_off_re = re.compile(r"^(Option [A-C]\b|NO-GO\b|Captured:\s*)", re.IGNORECASE) 

546 

547 def _is_general_constraint(e: DecisionEntry) -> bool: 

548 return not _one_off_re.match(e.rule or "") 

549 

550 def _extract_tokens(text: str) -> set[str]: 

551 """Extract snake_case and kebab-case identifiers (min 5 chars) and file paths.""" 

552 tokens: set[str] = set() 

553 for m in re.finditer(r"\b([a-z][a-z0-9]*(?:[_-][a-z0-9]+)+)\b", text): 

554 t = m.group(1).replace("-", "_") 

555 if len(t) >= 5: 

556 tokens.add(t) 

557 for m in re.finditer(r"([a-z][a-z0-9_/.-]+\.(?:py|yaml|md|json))", text): 

558 tokens.add(m.group(1)) 

559 return tokens 

560 

561 by_category: dict[str, list[DecisionEntry]] = defaultdict(list) 

562 for d in decisions: 

563 by_category[d.category].append(d) 

564 

565 clusters: list[tuple[list[DecisionEntry], str, list[str], bool]] = [] 

566 

567 for category in sorted(by_category): 

568 cat_entries = by_category[category] 

569 high_signal = len(cat_entries) >= 3 

570 general = [e for e in cat_entries if _is_general_constraint(e)] 

571 

572 if not general: 

573 continue 

574 

575 if high_signal: 

576 if len(general) >= 2: 

577 # Find tokens shared by at least 2 entries in this high-signal category 

578 tok_freq: dict[str, int] = {} 

579 for e in general: 

580 seen_in_entry: set[str] = set() 

581 for t in _extract_tokens((e.rationale or "") + " " + (e.rule or "")): 

582 if t not in seen_in_entry: 

583 tok_freq[t] = tok_freq.get(t, 0) + 1 

584 seen_in_entry.add(t) 

585 shared = sorted(t for t, c in tok_freq.items() if c >= 2)[:3] 

586 clusters.append((general, category, shared, True)) 

587 else: 

588 # Single general constraint in a high-signal category — still worth surfacing 

589 clusters.append((general, category, [], True)) 

590 else: 

591 # Low-signal category: only pair entries that share common tokens 

592 tok: dict[str, set[str]] = { 

593 e.id: _extract_tokens((e.rationale or "") + " " + (e.rule or "")) for e in general 

594 } 

595 used: set[str] = set() 

596 for i, e1 in enumerate(general): 

597 if e1.id in used: 

598 continue 

599 group = [e1] 

600 for e2 in general[i + 1 :]: 

601 if e2.id in used: 

602 continue 

603 if tok[e1.id] & tok[e2.id]: 

604 group.append(e2) 

605 used.add(e2.id) 

606 if len(group) >= 2: 

607 used.add(e1.id) 

608 shared_tokens = tok[e1.id].copy() 

609 for e in group[1:]: 

610 shared_tokens &= tok[e.id] 

611 clusters.append((group, category, sorted(shared_tokens)[:3], False)) 

612 

613 if not clusters: 

614 print("(no rule candidates found in current decisions log)") 

615 return 1 

616 

617 for group, category, shared, high_signal in clusters: 

618 ids = ", ".join(e.id for e in group) 

619 ref_str = " and reference " + ", ".join(shared) if shared else "" 

620 signal_str = " [high-signal]" if high_signal else "" 

621 best = max(group, key=lambda e: len(e.rule or "")) 

622 

623 print(f"[SUGGEST]{signal_str} {ids} share category={category}{ref_str}") 

624 print(f' — consider promoting to a rule: "{best.rule}"') 

625 for e in group: 

626 print(f"{e.id}: {e.rule}") 

627 print() 

628 

629 return 0 

630 

631 

632def _is_near_duplicate(rule_text: str, existing_rules: list[str]) -> bool: 

633 """Return True if rule_text shares ≥60% significant tokens with any existing rule.""" 

634 import re 

635 

636 def _tokens(text: str) -> set[str]: 

637 return {w.lower() for w in re.findall(r"\b[a-zA-Z]{4,}\b", text)} 

638 

639 new_tokens = _tokens(rule_text) 

640 if not new_tokens: 

641 return False 

642 

643 for existing in existing_rules: 

644 overlap = new_tokens & _tokens(existing) 

645 if overlap and len(overlap) / len(new_tokens) >= 0.6: 

646 return True 

647 

648 return False 

649 

650 

651_EXTRACTION_SCHEMA: dict = { 

652 "type": "object", 

653 "properties": { 

654 "candidates": { 

655 "type": "array", 

656 "items": { 

657 "type": "object", 

658 "properties": { 

659 "rule": {"type": "string"}, 

660 "rationale": {"type": "string"}, 

661 "category": { 

662 "type": "string", 

663 "enum": [ 

664 "architecture", 

665 "testing", 

666 "style", 

667 "security", 

668 "performance", 

669 "other", 

670 ], 

671 }, 

672 "confidence": {"type": "number"}, 

673 "scope": {"type": "string", "enum": ["global", "issue"]}, 

674 }, 

675 "required": ["rule", "rationale", "category", "confidence", "scope"], 

676 }, 

677 } 

678 }, 

679 "required": ["candidates"], 

680} 

681 

682 

683def _cmd_extract_from_completed(config, args, path) -> int: 

684 """Extract rules from completed issues via LLM and append to decisions.yaml.""" 

685 import json 

686 import subprocess 

687 from datetime import UTC, date, datetime 

688 from pathlib import Path 

689 

690 from little_loops.decisions import RuleEntry, add_entry, list_entries 

691 from little_loops.host_runner import resolve_host 

692 from little_loops.issue_history.parsing import scan_completed_issues 

693 

694 project_root = Path(config.project_root) 

695 issues_dir = project_root / config.issues.base_dir 

696 

697 since_date_str = getattr(args, "since", None) 

698 issue_filter = getattr(args, "issue", None) 

699 dry_run = getattr(args, "dry_run", False) 

700 min_confidence = float(getattr(args, "min_confidence", 0.7)) 

701 

702 # Filesystem scan always used (body content required for extraction) 

703 completed = scan_completed_issues(issues_dir) 

704 

705 if issue_filter: 

706 completed = [c for c in completed if c.issue_id == issue_filter] 

707 

708 if since_date_str: 

709 try: 

710 cutoff = date.fromisoformat(since_date_str) 

711 except ValueError: 

712 print( 

713 f"Error: invalid --since date {since_date_str!r}; expected YYYY-MM-DD", 

714 file=sys.stderr, 

715 ) 

716 return 1 

717 completed = [c for c in completed if c.completed_date and c.completed_date >= cutoff] 

718 

719 if not completed: 

720 print("No completed issues found matching filters.") 

721 return 0 

722 

723 # Load existing entries for deduplication 

724 existing = list_entries(path) 

725 existing_issue_ids = {e.issue for e in existing if getattr(e, "issue", None)} 

726 existing_rule_texts = [ 

727 e.rule.lower() # type: ignore[union-attr] 

728 for e in existing 

729 if getattr(e, "rule", None) and isinstance(e.rule, str) # type: ignore[union-attr] 

730 ] 

731 

732 timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 

733 

734 total_candidates = 0 

735 total_written = 0 

736 total_skipped = 0 

737 

738 for issue in completed: 

739 # Level-1 dedup: skip if any entry already references this issue 

740 if issue.issue_id in existing_issue_ids: 

741 total_skipped += 1 

742 continue 

743 

744 if not issue.path or not issue.path.exists(): 

745 continue 

746 

747 body = issue.path.read_text(encoding="utf-8") 

748 

749 prompt = ( 

750 f"Issue: {issue.issue_id}\n" 

751 f"Type: {issue.issue_type}\n" 

752 f"Body:\n{body[:4000]}\n\n" 

753 "Extract any decisions, constraints, or rules that should guide future coding agents.\n" 

754 "Only extract rules general enough to apply to future work (not one-off tactical decisions).\n" 

755 "For each candidate return:\n" 

756 " - rule: imperative sentence, ≤ 120 chars\n" 

757 " - rationale: why this rule exists, drawn from the issue context\n" 

758 " - category: architecture | testing | style | security | performance | other\n" 

759 " - confidence: 0.0–1.0 (how likely this generalizes beyond this one issue)\n" 

760 " - scope: global | issue\n" 

761 "Return an empty candidates array if no generalizable rules can be extracted." 

762 ) 

763 

764 invocation = resolve_host().build_blocking_json(prompt=prompt, model="sonnet") 

765 llm_args = list(invocation.args) + [ 

766 "--json-schema", 

767 json.dumps(_EXTRACTION_SCHEMA), 

768 "--no-session-persistence", 

769 ] 

770 

771 try: 

772 proc = subprocess.run( 

773 [invocation.binary, *llm_args], 

774 capture_output=True, 

775 text=True, 

776 timeout=120, 

777 ) 

778 except subprocess.TimeoutExpired: 

779 print(f"Warning: LLM call timed out for {issue.issue_id}", file=sys.stderr) 

780 continue 

781 except FileNotFoundError: 

782 print(f"Error: host CLI not found ({invocation.binary})", file=sys.stderr) 

783 return 1 

784 

785 if proc.returncode != 0: 

786 print( 

787 f"Warning: LLM call failed for {issue.issue_id}: {proc.stderr[:200]}", 

788 file=sys.stderr, 

789 ) 

790 continue 

791 

792 # Parse CLI JSON envelope 

793 candidates: list[dict] = [] 

794 try: 

795 stdout = proc.stdout.strip() 

796 try: 

797 envelope = json.loads(stdout) 

798 except json.JSONDecodeError: 

799 lines = [ln for ln in stdout.split("\n") if ln.strip()] 

800 envelope = json.loads(lines[-1]) if lines else {} 

801 

802 if envelope.get("subtype") == "error_max_structured_output_retries": 

803 print( 

804 f"Warning: LLM schema retries exhausted for {issue.issue_id}", 

805 file=sys.stderr, 

806 ) 

807 continue 

808 

809 if isinstance(envelope.get("structured_output"), dict): 

810 result = envelope["structured_output"] 

811 else: 

812 raw = envelope.get("result", "") 

813 result = json.loads(raw) if isinstance(raw, str) and raw else {} 

814 

815 candidates = result.get("candidates", []) 

816 except Exception as exc: 

817 print( 

818 f"Warning: failed to parse LLM response for {issue.issue_id}: {exc}", 

819 file=sys.stderr, 

820 ) 

821 continue 

822 

823 total_candidates += len(candidates) 

824 

825 for candidate in candidates: 

826 rule_text = (candidate.get("rule") or "").strip()[:120] 

827 rationale = (candidate.get("rationale") or "").strip() 

828 category = candidate.get("category") or "other" 

829 confidence = float(candidate.get("confidence") or 0.0) 

830 scope = candidate.get("scope") or "issue" 

831 

832 if not rule_text: 

833 continue 

834 

835 if confidence < min_confidence: 

836 total_skipped += 1 

837 continue 

838 

839 # Level-2 dedup: near-duplicate rule text check 

840 if _is_near_duplicate(rule_text, existing_rule_texts): 

841 total_skipped += 1 

842 continue 

843 

844 existing_rules_typed = list_entries(path, type="rule") 

845 cat_prefix = category[:4].upper() 

846 entry_id = f"{cat_prefix}-{len(existing_rules_typed) + 1:03d}" 

847 

848 entry = RuleEntry( 

849 id=entry_id, 

850 timestamp=timestamp, 

851 category=category, 

852 labels=["extracted", scope], 

853 rationale=rationale, 

854 rule=rule_text, 

855 enforcement="advisory", 

856 issue=issue.issue_id, 

857 ) 

858 

859 if dry_run: 

860 print( 

861 f"[DRY-RUN] {entry_id} ({category}, confidence={confidence:.2f}): {rule_text}" 

862 ) 

863 else: 

864 add_entry(entry, path) 

865 existing_rule_texts.append(rule_text.lower()) 

866 total_written += 1 

867 

868 print( 

869 f"Extraction complete: {total_candidates} candidates found, " 

870 f"{total_written} written, {total_skipped} skipped (duplicate/low-confidence)." 

871 ) 

872 return 0 

873 

874 

875def _cmd_promote(args, path, load_decisions, save_decisions, RuleEntry, DecisionEntry) -> int: 

876 entry_id = args.entry_id 

877 enforcement = getattr(args, "enforcement", "required") 

878 

879 entries = load_decisions(path) 

880 

881 target = None 

882 idx = None 

883 for i, e in enumerate(entries): 

884 if e.id == entry_id: 

885 target = e 

886 idx = i 

887 break 

888 

889 if target is None: 

890 print(f"Error: entry {entry_id!r} not found", file=sys.stderr) 

891 return 1 

892 

893 if not isinstance(target, DecisionEntry): 

894 print( 

895 f"Error: entry {entry_id!r} is type {target.type!r}, not 'decision'; cannot promote", 

896 file=sys.stderr, 

897 ) 

898 return 1 

899 

900 rule = RuleEntry( 

901 id=target.id, 

902 timestamp=target.timestamp, 

903 category=target.category, 

904 labels=list(target.labels), 

905 rationale=target.rationale, 

906 rule=target.rule, 

907 enforcement=enforcement, 

908 supersedes=None, 

909 issue=target.issue, 

910 ) 

911 

912 entries[idx] = rule 

913 save_decisions(entries, path) 

914 

915 if enforcement == "required": 

916 _cmd_sync(path) 

917 

918 print(f"Promoted {entry_id} → rule (enforcement: {enforcement})") 

919 return 0