1"""JSON Schema generation for all 23 LLEvent types.

2

3 Generates one JSON Schema (draft-07) file per event type to docs/reference/schemas/.

4 Schemas validate the flat wire format: {"event": type, "ts": timestamp, ...payload}.

5

6 Usage:

7 python -m little_loops.generate_schemas [--output OUTPUT_DIR]

8

9 Or via CLI:

10 ll-generate-schemas [--output OUTPUT_DIR]

11"""

12

13 from __future__ import annotations

14

15 import json

16 from pathlib import Path

17 from typing import Any

18

19# ---------------------------------------------------------------------------

20# Schema building helpers

21# ---------------------------------------------------------------------------

22

23_DRAFT07 = "http://json-schema.org/draft-07/schema#"

24

25_BASE_PROPS: dict[str, Any] = {

26 "event": {"type": "string", "description": "Event type identifier"},

27 "ts": {"type": "string", "format": "date-time", "description": "ISO 8601 timestamp"},

28}

29

30_BASE_REQUIRED = ["event", "ts"]

31

32

33 def _str(description: str) -> dict[str, Any]:

34 return {"type": "string", "description": description}

35

36

37 def _int(description: str) -> dict[str, Any]:

38 return {"type": "integer", "description": description}

39

40

41 def _number(description: str) -> dict[str, Any]:

42 return {"type": "number", "description": description}

43

44

45 def _bool(description: str) -> dict[str, Any]:

46 return {"type": "boolean", "description": description}

47

48

49 def _nullable_str(description: str) -> dict[str, Any]:

50 return {"type": ["string", "null"], "description": description}

51

52

53 def _nullable_bool(description: str) -> dict[str, Any]:

54 return {"type": ["boolean", "null"], "description": description}

55

56

57 def _schema(

58 event_type: str,

59 title: str,

60 description: str,

61 extra_props: dict[str, Any],

62 extra_required: list[str] | None = None,

63) -> dict[str, Any]:

64 """Build a complete JSON Schema dict for an event type."""

65 return {

66 "$schema": _DRAFT07,

67 "$id": f"little-loops://event-{event_type}.json",

68 "title": title,

69 "description": description,

70 "type": "object",

71 "required": _BASE_REQUIRED + (extra_required or []),

72 "properties": {**_BASE_PROPS, **extra_props},

73 "additionalProperties": True,

74 }

75

76

77# ---------------------------------------------------------------------------

78# Schema definitions — all 26 LLEvent types

79# Source of truth: docs/reference/EVENT-SCHEMA.md

80# ---------------------------------------------------------------------------

81

82 SCHEMA_DEFINITIONS: dict[str, dict[str, Any]] = {

83 # FSM Executor (15 types)

84 "loop_start": _schema(

85 "loop_start",

86 "Loop Start",

87 "Emitted when an FSM loop begins execution.",

88 {"loop": _str("Loop name")},

89 ["loop"],

90 ),

91 "state_enter": _schema(

92 "state_enter",

93 "State Enter",

94 "Emitted when the FSM enters a state.",

95 {

96 "state": _str("State name"),

97 "iteration": _int("Iteration count (1-based)"),

98 },

99 ["state", "iteration"],

100 ),

101 "route": _schema(

102 "route",

103 "Route",

104 "Emitted when the FSM transitions between states.",

105 {

106 "from": _str("Source state name"),

107 "to": _str("Destination state name"),

108 "reason": _str("Optional transition reason"),

109 },

110 ["from", "to"],

111 ),

112 "action_start": _schema(

113 "action_start",

114 "Action Start",

115 "Emitted when a state action begins.",

116 {

117 "action": _str("Action name or command"),

118 "is_prompt": _bool("True if action is a Claude prompt, false for shell command"),

119 },

120 ["action", "is_prompt"],

121 ),

122 "action_output": _schema(

123 "action_output",

124 "Action Output",

125 "Emitted for each line of output from a running action.",

126 {"line": _str("Output line text")},

127 ["line"],

128 ),

129 "action_complete": _schema(

130 "action_complete",

131 "Action Complete",

132 "Emitted when an action finishes.",

133 {

134 "exit_code": _int("Process exit code (0 = success)"),

135 "duration_ms": _int("Execution duration in milliseconds"),

136 "output_preview": _nullable_str("Short preview of output, null if none"),

137 "is_prompt": _bool("True if action was a Claude prompt"),

138 "session_jsonl": _nullable_str(

139 "Path to Claude session JSONL file (prompt-only, null for shell commands)"

140 ),

141 "input_tokens": _int("Input tokens consumed (prompt/slash_command only)"),

142 "output_tokens": _int("Output tokens generated (prompt/slash_command only)"),

143 "cache_read_tokens": _int("Cache read tokens consumed (prompt/slash_command only)"),

144 "cache_creation_tokens": _int(

145 "Cache creation tokens written (prompt/slash_command only)"

146 ),

147 "model": _str("Model ID reported by the host CLI (prompt/slash_command only)"),

148 },

149 ["exit_code", "duration_ms", "is_prompt"],

150 ),

151 "action_error": _schema(

152 "action_error",

153 "Action Error",

154 "Emitted when an action raises an unhandled exception that is routed to on_error.",

155 {

156 "state": _str("State name whose action raised"),

157 "error": _str("String representation of the raised exception"),

158 "route": _str("Route taken in response to the exception (always 'on_error')"),

159 },

160 ["state", "error", "route"],

161 ),

162 "evaluate": _schema(

163 "evaluate",

164 "Evaluate",

165 "Emitted when an evaluator runs against action output.",

166 {

167 "type": _str("Evaluator type identifier"),

168 "verdict": _str("Evaluator verdict (e.g. pass, fail, retry)"),

169 },

170 ["type", "verdict"],

171 ),

172 "retry_exhausted": _schema(

173 "retry_exhausted",

174 "Retry Exhausted",

175 "Emitted when all retries for a state are exhausted.",

176 {

177 "state": _str("State name that exhausted retries"),

178 "retries": _int("Number of retries attempted"),

179 "next": _str("Next state the FSM transitions to"),

180 },

181 ["state", "retries", "next"],

182 ),

183 "cycle_detected": _schema(

184 "cycle_detected",

185 "Cycle Detected",

186 "Emitted when the same edge is traversed too many times, indicating a tight infinite loop.",

187 {

188 "edge": _str("Edge key (from_state->to_state) that triggered detection"),

189 "from": _str("Source state of the cyclic edge"),

190 "to": _str("Target state of the cyclic edge"),

191 "count": _int("Number of times this edge was traversed"),

192 "max": _int("Configured max_edge_revisits limit"),

193 },

194 ["edge", "from", "to", "count", "max"],

195 ),

196 "stall_detected": _schema(

197 "stall_detected",

198 "Stall Detected",

199 'Emitted when the FSM stall detector observes `window` consecutive iterations with an identical (state, exit_code, verdict) triple. Either terminates the run (action="abort") or routes to a recovery state (action="route:<state>"). See FEAT-1637.',

200 {

201 "state": _str("State name whose triple repeated"),

202 "exit_code": _int("Action exit code observed in the repeating triple"),

203 "verdict": _str("Evaluator verdict observed in the repeating triple"),

204 "consecutive": _int("Number of consecutive identical triples that fired the detector"),

205 "action": _str('Either "abort" or "route:<target_state>"'),

206 },

207 ["state", "exit_code", "verdict", "consecutive", "action"],

208 ),

209 "rate_limit_exhausted": _schema(

210 "rate_limit_exhausted",

211 "Rate Limit Exhausted",

212 "Emitted when the wall-clock rate-limit budget is spent across short + long tiers.",

213 {

214 "state": _str("State name that exhausted rate-limit retries"),

215 "retries": _int("Total rate-limit retries attempted (short + long)"),

216 "short_retries": _int("Retries attempted in the short-burst tier"),

217 "long_retries": _int("Retries attempted in the long-wait tier"),

218 "total_wait_seconds": _number(

219 "Accumulated wall-clock seconds spent in rate-limit waits"

220 ),

221 "next": _nullable_str("Next state the FSM transitions to, or null if none"),

222 },

223 ["state", "retries"],

224 ),

225 "rate_limit_storm": _schema(

226 "rate_limit_storm",

227 "Rate Limit Storm",

228 "Emitted when consecutive rate_limit_exhausted events reach the storm threshold.",

229 {

230 "state": _str("State name that triggered the storm threshold"),

231 "count": _int("Consecutive rate_limit_exhausted count at emission time"),

232 },

233 ["state", "count"],

234 ),

235 "rate_limit_waiting": _schema(

236 "rate_limit_waiting",

237 "Rate Limit Waiting",

238 "Heartbeat emitted every ~60s during a long-wait rate-limit sleep so UIs can show live progress.",

239 {

240 "state": _str("State name currently waiting on rate-limit recovery"),

241 "elapsed_seconds": _number("Wall-clock seconds elapsed in the current tier's sleep"),

242 "next_attempt_at": _number("Unix timestamp when this sleep is scheduled to end"),

243 "total_waited_seconds": _number(

244 "Accumulated wall-clock seconds across all rate-limit waits for this state"

245 ),

246 "budget_seconds": _int("Configured rate_limit_max_wait_seconds budget"),

247 "tier": _str("Wait tier identifier (currently only 'long_wait')"),

248 },

249 ["state", "elapsed_seconds", "next_attempt_at"],

250 ),

251 "throttle_warn": _schema(

252 "throttle_warn",

253 "Throttle Warn",

254 "Emitted when a state's tool-call count reaches warn_max within a single state visit.",

255 {

256 "state": _str("State name where throttle warning was triggered"),

257 "count": _int("Current tool-call count at time of emission"),

258 "normal_max": _int("Configured normal_max threshold for this state"),

259 "warn_max": _int("Configured warn_max threshold for this state"),

260 "hard_max": _int("Configured hard_max threshold for this state"),

261 },

262 ["state", "count", "warn_max", "hard_max"],

263 ),

264 "throttle_hard": _schema(

265 "throttle_hard",

266 "Throttle Hard",

267 "Emitted when a state's tool-call count reaches hard_max, triggering transition to on_throttle_hard.",

268 {

269 "state": _str("State name where hard throttle was triggered"),

270 "count": _int("Current tool-call count at time of emission"),

271 "hard_max": _int("Configured hard_max threshold for this state"),

272 "next": _str("Target state (on_throttle_hard or on_error, or null)"),

273 },

274 ["state", "count", "hard_max"],

275 ),

276 "throttle_stop": _schema(

277 "throttle_stop",

278 "Throttle Stop",

279 "Emitted when a state's tool-call count exceeds hard_max with no on_throttle_hard target, causing a hard stop.",

280 {

281 "state": _str("State name where stop throttle was triggered"),

282 "count": _int("Current tool-call count at time of emission"),

283 "hard_max": _int("Configured hard_max threshold for this state"),

284 },

285 ["state", "count", "hard_max"],

286 ),

287 # FEAT-1283: type=learning state dispatch events

288 "learning_target_proven": _schema(

289 "learning_target_proven",

290 "Learning Target Proven",

291 "Emitted when a target's learning-tests registry record is found with status='proven'. The state will advance to the next target (or to on_yes when all targets are proven).",

292 {

293 "state": _str("State name executing the learning dispatch"),

294 "target": _str("Target identifier (e.g. 'Anthropic SDK streaming')"),

295 },

296 ["state", "target"],

297 ),

298 "learning_target_stale": _schema(

299 "learning_target_stale",

300 "Learning Target Stale",

301 "Emitted when a target's registry record is missing or has status='stale', immediately before /ll:explore-api is invoked to (re-)prove it.",

302 {

303 "state": _str("State name executing the learning dispatch"),

304 "target": _str("Target identifier"),

305 "cause": _str("Why the record was treated as stale: 'missing' or 'stale'"),

306 },

307 ["state", "target", "cause"],

308 ),

309 "learning_explore_invoked": _schema(

310 "learning_explore_invoked",

311 "Learning Explore Invoked",

312 "Emitted just before the learning state invokes /ll:explore-api for a target. Pairs with action_start/action_complete from the underlying skill invocation.",

313 {

314 "state": _str("State name executing the learning dispatch"),

315 "target": _str("Target identifier being explored"),

316 "attempt": _int("Attempt number, 1-based, capped by learning.max_retries"),

317 },

318 ["state", "target", "attempt"],

319 ),

320 "learning_target_refuted": _schema(

321 "learning_target_refuted",

322 "Learning Target Refuted",

323 "Emitted when a target's registry record has status='refuted'. Routes to on_blocked / on_no.",

324 {

325 "state": _str("State name executing the learning dispatch"),

326 "target": _str("Target identifier"),

327 },

328 ["state", "target"],

329 ),

330 "learning_complete": _schema(

331 "learning_complete",

332 "Learning Complete",

333 "Emitted when every target in a learning state has been proven. The state transitions via on_yes.",

334 {

335 "state": _str("State name executing the learning dispatch"),

336 "targets": {

337 "type": "array",

338 "description": "List of target identifiers that were all proven",

339 "items": {"type": "string"},

340 },

341 },

342 ["state", "targets"],

343 ),

344 "learning_blocked": _schema(

345 "learning_blocked",

346 "Learning Blocked",

347 "Emitted when a learning state cannot advance: a target is refuted, or /ll:explore-api retries are exhausted without proving the target.",

348 {

349 "state": _str("State name executing the learning dispatch"),

350 "target": _str("Target that blocked progress"),

351 "reason": _str("'refuted' or 'retries_exhausted'"),

352 },

353 ["state", "target", "reason"],

354 ),

355 "handoff_detected": _schema(

356 "handoff_detected",

357 "Handoff Detected",

358 "Emitted when a context-limit handoff is detected in a prompt action.",

359 {

360 "state": _str("State name where handoff was detected"),

361 "iteration": _int("Iteration count at handoff"),

362 "continuation": _str("Continuation prompt text"),

363 },

364 ["state", "iteration", "continuation"],

365 ),

366 "handoff_spawned": _schema(

367 "handoff_spawned",

368 "Handoff Spawned",

369 "Emitted when a new process is spawned to continue after a handoff.",

370 {

371 "pid": _int("Process ID of the spawned continuation process"),

372 "state": _str("State name the continuation will resume from"),

373 },

374 ["pid", "state"],

375 ),

376 "loop_complete": _schema(

377 "loop_complete",

378 "Loop Complete",

379 "Emitted when an FSM loop finishes execution.",

380 {

381 "final_state": _str(

382 "Name of the state at termination. Usually the last state entered; "

383 "when terminated_by='timeout' this may be a state that was routed to "

384 "but never entered — with one exception: if that pending state is a "

385 "shell action, the executor flushes it (emits state_enter with "

386 "flushed=true and runs its action) before honoring the timeout, so "

387 "state_enter for final_state is always emitted before loop_complete "

388 "(BUG-1226)."

389 ),

390 "iterations": _int("Total number of iterations executed"),

391 "terminated_by": _str(

392 "What caused loop termination (e.g. terminal_state, max_iterations)"

393 ),

394 "error": _str(

395 "Error message explaining why the loop crashed. "

396 "Present only when terminated_by='error'."

397 ),

398 },

399 ["final_state", "iterations", "terminated_by"],

400 ),

401 "max_steps_summary": _schema(

402 "max_steps_summary",

403 "Max Steps Summary",

404 "Emitted when the step cap fires and on_max_steps is set; "

405 "signals that a summary state will run before the loop terminates.",

406 {

407 "summary_state": _str("Name of the summary state the executor transitions to"),

408 "iterations": _int("Step count at which the cap fired"),

409 },

410 ["summary_state", "iterations"],

411 ),

412 "max_iterations_reached_summary": _schema(

413 "max_iterations_reached_summary",

414 "Max Iterations Reached Summary",

415 "Emitted when the full-pass cap fires and on_max_iterations is set; "

416 "signals that a summary state will run before the loop terminates.",

417 {

418 "summary_state": _str("Name of the summary state the executor transitions to"),

419 "iteration_count": _int("Full-pass count at which the cap fired"),

420 },

421 ["summary_state", "iteration_count"],

422 ),

423 # FSM Persistence (1 type)

424 "loop_resume": _schema(

425 "loop_resume",

426 "Loop Resume",

427 "Emitted when a previously interrupted loop resumes from a persisted checkpoint.",

428 {

429 "loop": _str("Loop name"),

430 "from_state": _str("State the loop resumes from"),

431 "iteration": _int("Iteration count at resume"),

432 "from_handoff": _bool("True if resuming from a context-limit handoff"),

433 "continuation_prompt": _nullable_str(

434 "Continuation prompt text (only present when from_handoff is true)"

435 ),

436 },

437 ["loop", "from_state", "iteration"],

438 ),

439 # StateManager (2 types)

440 "state.issue_completed": _schema(

441 "state.issue_completed",

442 "State: Issue Completed",

443 "Emitted by StateManager when an issue transitions to completed status.",

444 {

445 "issue_id": _str("Issue identifier"),

446 "status": {"type": "string", "enum": ["completed"], "description": "Completion status"},

447 },

448 ["issue_id", "status"],

449 ),

450 "state.issue_failed": _schema(

451 "state.issue_failed",

452 "State: Issue Failed",

453 "Emitted by StateManager when an issue transitions to failed status.",

454 {

455 "issue_id": _str("Issue identifier"),

456 "reason": _str("Failure reason description"),

457 "status": {"type": "string", "enum": ["failed"], "description": "Failure status"},

458 },

459 ["issue_id", "reason", "status"],

460 ),

461 # Issue Lifecycle (6 types)

462 "issue.failure_captured": _schema(

463 "issue.failure_captured",

464 "Issue: Failure Captured",

465 "Emitted when an issue failure is captured and persisted as a bug report.",

466 {

467 "issue_id": _str("Issue identifier"),

468 "file_path": _str("Path to the issue file"),

469 "parent_issue_id": _str("Identifier of the parent issue that failed"),

470 },

471 ["issue_id", "file_path", "parent_issue_id"],

472 ),

473 "issue.closed": _schema(

474 "issue.closed",

475 "Issue: Closed",

476 "Emitted when an issue is closed.",

477 {

478 "issue_id": _str("Issue identifier"),

479 "file_path": _str("Path to the issue file"),

480 "close_reason": _str("Reason the issue was closed"),

481 },

482 ["issue_id", "file_path", "close_reason"],

483 ),

484 "issue.completed": _schema(

485 "issue.completed",

486 "Issue: Completed",

487 "Emitted when an issue is successfully completed.",

488 {

489 "issue_id": _str("Issue identifier"),

490 "file_path": _str("Path to the completed issue file"),

491 },

492 ["issue_id", "file_path"],

493 ),

494 "issue.deferred": _schema(

495 "issue.deferred",

496 "Issue: Deferred",

497 "Emitted when an issue is deferred (parked for later).",

498 {

499 "issue_id": _str("Issue identifier"),

500 "file_path": _str("Path to the deferred issue file"),

501 "reason": _str("Reason the issue was deferred"),

502 },

503 ["issue_id", "file_path", "reason"],

504 ),

505 "issue.skipped": _schema(

506 "issue.skipped",

507 "Issue: Skipped",

508 "Emitted when an issue is skipped during automated processing.",

509 {

510 "issue_id": _str("Issue identifier"),

511 "file_path": _str("Path to the issue file"),

512 "reason": _str("Reason the issue was skipped"),

513 },

514 ["issue_id", "file_path", "reason"],

515 ),

516 "issue.started": _schema(

517 "issue.started",

518 "Issue: Started",

519 "Emitted when a deferred issue is undeferred and returned to active processing.",

520 {

521 "issue_id": _str("Issue identifier"),

522 "file_path": _str("Path to the issue file"),

523 "reason": _str("Reason the issue was restarted"),

524 },

525 ["issue_id", "file_path", "reason"],

526 ),

527 # Parallel Orchestrator (1 type)

528 "parallel.worker_completed": _schema(

529 "parallel.worker_completed",

530 "Parallel: Worker Completed",

531 "Emitted by the parallel orchestrator when a worker finishes processing an issue.",

532 {

533 "issue_id": _str("Issue identifier processed by the worker"),

534 "worker_name": _str("Worker name or identifier"),

535 "status": _str("Completion status (e.g. completed, failed, deferred)"),

536 "duration_seconds": {"type": "number", "description": "Wall-clock time in seconds"},

537 },

538 ["issue_id", "worker_name", "status", "duration_seconds"],

539 ),

540}

541

542

543 def event_type_to_filename(event_type: str) -> str:

544 """Convert event type to safe filename (replace '.' with '_')."""

545 return event_type.replace(".", "_") + ".json"

546

547

548 def generate_schemas(output_dir: Path) -> list[Path]:

549 """Generate JSON Schema files for all 23 LLEvent types.

550

551 Args:

552 output_dir: Directory to write schema files into. Created if it doesn't exist.

553

554 Returns:

555 List of paths to generated files.

556 """

557 output_dir.mkdir(parents=True, exist_ok=True)

558 generated: list[Path] = []

559 for event_type, schema in SCHEMA_DEFINITIONS.items():

560 filename = event_type_to_filename(event_type)

561 path = output_dir / filename

562 path.write_text(json.dumps(schema, indent=2) + "\n")

563 generated.append(path)

564 return generated

565

566

567 if __name__ == "__main__":

568 import sys

569

570 output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("docs/reference/schemas")

571 paths = generate_schemas(output)

572 print(f"Generated {len(paths)} schemas in {output}/")