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

156 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -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"], 

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 "--no-outcome", 

40 action="store_true", 

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

42 ) 

43 list_p.add_argument( 

44 "--before", 

45 default=None, 

46 metavar="ISO-8601", 

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

48 ) 

49 list_p.add_argument( 

50 "--scope", 

51 default=None, 

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

53 ) 

54 list_p.add_argument( 

55 "--format", 

56 "-f", 

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

58 default="text", 

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

60 ) 

61 list_p.add_argument( 

62 "--active-only", 

63 action="store_true", 

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

65 ) 

66 add_config_arg(list_p) 

67 

68 # -- add -- 

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

70 add_p.add_argument( 

71 "--type", 

72 required=True, 

73 choices=["rule", "decision", "exception"], 

74 help="Entry type", 

75 ) 

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

77 add_p.add_argument( 

78 "--rule", 

79 default=None, 

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

81 ) 

82 add_p.add_argument( 

83 "--rationale", 

84 required=True, 

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

86 ) 

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

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

89 add_p.add_argument( 

90 "--enforcement", 

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

92 default="advisory", 

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

94 ) 

95 add_p.add_argument( 

96 "--rule-ref", 

97 default=None, 

98 dest="rule_ref", 

99 metavar="RULE-ID", 

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

101 ) 

102 add_p.add_argument( 

103 "--alternatives-rejected", 

104 default=None, 

105 dest="alternatives_rejected", 

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

107 ) 

108 add_p.add_argument( 

109 "--supersedes", 

110 default=None, 

111 metavar="ENTRY-ID", 

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

113 ) 

114 add_p.add_argument( 

115 "--scope", 

116 default="issue", 

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

118 ) 

119 add_p.add_argument( 

120 "--id", 

121 default=None, 

122 dest="entry_id", 

123 metavar="ENTRY-ID", 

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

125 ) 

126 add_config_arg(add_p) 

127 

128 # -- outcome -- 

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

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

131 outcome_p.add_argument( 

132 "--result", 

133 required=True, 

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

135 help="Outcome result", 

136 ) 

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

138 outcome_p.add_argument( 

139 "--measured-at", 

140 default=None, 

141 dest="measured_at", 

142 metavar="ISO-8601", 

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

144 ) 

145 outcome_p.add_argument( 

146 "--force", 

147 action="store_true", 

148 help="Overwrite an existing outcome", 

149 ) 

150 add_config_arg(outcome_p) 

151 

152 # -- generate (stub) -- 

153 gen_p = subsubs.add_parser( 

154 "generate", 

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

156 ) 

157 gen_p.add_argument( 

158 "--from", 

159 dest="source", 

160 default="completed", 

161 choices=["completed"], 

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

163 ) 

164 add_config_arg(gen_p) 

165 

166 # -- sync (stub) -- 

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

168 add_config_arg(sync_p) 

169 

170 return p 

171 

172 

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

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

175 from pathlib import Path 

176 

177 from little_loops.decisions import ( 

178 DecisionEntry, 

179 ExceptionEntry, 

180 RuleEntry, 

181 add_entry, 

182 list_entries, 

183 resolve_active, 

184 set_outcome, 

185 ) 

186 

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

188 args._decisions_parser.print_help() 

189 return 1 

190 

191 # Resolve the decisions log path from config 

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

193 

194 sub = args.subcommand 

195 

196 if sub == "list": 

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

198 

199 if sub == "add": 

200 return _cmd_add( 

201 args, path, RuleEntry, DecisionEntry, ExceptionEntry, add_entry, list_entries 

202 ) 

203 

204 if sub == "outcome": 

205 return _cmd_outcome(args, path, set_outcome) 

206 

207 if sub == "generate": 

208 from little_loops.decisions import generate_from_completed 

209 

210 count = generate_from_completed(config) 

211 print( 

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

213 ) 

214 return 0 

215 

216 if sub == "sync": 

217 return _cmd_sync(path) 

218 

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

220 return 1 

221 

222 

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

224 import json 

225 

226 entries = list_entries( 

227 path=path, 

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

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

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

231 ) 

232 

233 # Post-filters 

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

235 from little_loops.decisions import DecisionEntry 

236 

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

238 

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

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

241 

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

243 from little_loops.decisions import DecisionEntry 

244 

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

246 

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

248 entries = resolve_active(entries) 

249 

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

251 if fmt == "json": 

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

253 return 0 

254 

255 if not entries: 

256 print("(no entries)") 

257 return 0 

258 

259 for entry in entries: 

260 _print_entry(entry) 

261 return 0 

262 

263 

264def _print_entry(entry) -> None: 

265 from little_loops.decisions import DecisionEntry, ExceptionEntry, RuleEntry 

266 

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

268 print( 

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

270 ) 

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

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

273 elif isinstance(entry, ExceptionEntry): 

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

275 if entry.rationale: 

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

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

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

279 

280 

281def _cmd_add(args, path, RuleEntry, DecisionEntry, ExceptionEntry, add_entry, list_entries) -> int: 

282 entry_type = args.type 

283 

284 # Validate type-specific required fields 

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

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

287 return 1 

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

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

290 return 1 

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

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

293 return 1 

294 

295 # Generate an ID if not provided 

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

297 if not entry_id: 

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

299 prefix = { 

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

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

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

303 }[entry_type] 

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

305 

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

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

308 

309 if entry_type == "rule": 

310 entry = RuleEntry( 

311 id=entry_id, 

312 timestamp=timestamp, 

313 category=args.category, 

314 labels=labels, 

315 rationale=args.rationale, 

316 rule=args.rule, 

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

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

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

320 ) 

321 elif entry_type == "decision": 

322 entry = DecisionEntry( 

323 id=entry_id, 

324 timestamp=timestamp, 

325 category=args.category, 

326 labels=labels, 

327 rationale=args.rationale, 

328 rule=args.rule, 

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

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

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

332 ) 

333 else: # exception 

334 entry = ExceptionEntry( 

335 id=entry_id, 

336 timestamp=timestamp, 

337 category=args.category, 

338 labels=labels, 

339 rationale=args.rationale, 

340 rule_ref=args.rule_ref, 

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

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

343 ) 

344 

345 add_entry(entry, path=path) 

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

347 return 0 

348 

349 

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

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

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

353 ) 

354 try: 

355 set_outcome( 

356 args.id, 

357 args.result, 

358 measured_at, 

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

360 path=path, 

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

362 ) 

363 except KeyError: 

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

365 return 1 

366 except TypeError as exc: 

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

368 return 1 

369 except ValueError as exc: 

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

371 return 1 

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

373 return 0 

374 

375 

376def _cmd_sync(path) -> int: 

377 try: 

378 from little_loops.decisions_sync import sync_to_local_md 

379 

380 sync_to_local_md(path=path) 

381 except ImportError: 

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

383 return 1 

384 return 0