ai-push-hooks 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +80 -525
  3. package/SECURITY.md +102 -14
  4. package/ai-push-hooks.toml +9 -2
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/package.json +3 -2
  7. package/pyproject.toml +1 -1
  8. package/src/ai_push_hooks/artifacts.py +67 -13
  9. package/src/ai_push_hooks/config.py +575 -22
  10. package/src/ai_push_hooks/engine.py +116 -7
  11. package/src/ai_push_hooks/executors/apply.py +75 -36
  12. package/src/ai_push_hooks/executors/ask.py +224 -0
  13. package/src/ai_push_hooks/executors/exec.py +17 -801
  14. package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
  15. package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
  16. package/src/ai_push_hooks/executors/runners/claude.py +286 -0
  17. package/src/ai_push_hooks/executors/runners/codex.py +254 -0
  18. package/src/ai_push_hooks/executors/runners/command.py +178 -0
  19. package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
  20. package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
  22. package/src/ai_push_hooks/executors/runners/process.py +464 -0
  23. package/src/ai_push_hooks/executors/runners/registry.py +117 -0
  24. package/src/ai_push_hooks/executors/step_commands.py +478 -0
  25. package/src/ai_push_hooks/git_utils.py +834 -0
  26. package/src/ai_push_hooks/hook.py +1 -1
  27. package/src/ai_push_hooks/modules/beads.py +1 -1
  28. package/src/ai_push_hooks/modules/docs.py +129 -89
  29. package/src/ai_push_hooks/modules/pr.py +1 -1
  30. package/src/ai_push_hooks/plugin_loader.py +422 -0
  31. package/src/ai_push_hooks/plugins.py +134 -0
  32. package/src/ai_push_hooks/prompts_builtin.py +9 -2
  33. package/src/ai_push_hooks/types.py +407 -75
  34. package/vendor/README.md +15 -0
  35. package/vendor/requirements.txt +1 -0
  36. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  37. package/src/ai_push_hooks/executors/llm.py +0 -624
@@ -3,18 +3,26 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import pathlib
6
+ import re
6
7
  import stat
7
8
  import sys
9
+ import threading
8
10
  from dataclasses import dataclass, field
9
11
  from datetime import datetime, timezone
10
- from typing import Any
12
+ from typing import Any, ClassVar
11
13
 
12
- READ_ONLY_STEP_TYPES = frozenset({"collect", "llm"})
13
- PROMPTABLE_STEP_TYPES = frozenset({"llm", "apply"})
14
- SUPPORTED_STEP_TYPES = frozenset({"collect", "llm", "apply", "exec", "assert"})
14
+ READ_ONLY_STEP_TYPES = frozenset({"collect", "ask"})
15
+ PROMPTABLE_STEP_TYPES = frozenset({"ask", "apply"})
16
+ SUPPORTED_STEP_TYPES = frozenset({"collect", "ask", "apply", "exec", "assert"})
17
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
15
18
  FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
16
19
  ZERO_OID_LENGTHS = frozenset({40, 64})
17
20
 
21
+ _ANSI_ESCAPE_PATTERN = re.compile(
22
+ r"(?:\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_])"
23
+ )
24
+ _UNSAFE_TERMINAL_CONTROL_PATTERN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
25
+
18
26
 
19
27
  class HookError(RuntimeError):
20
28
  pass
@@ -85,6 +93,17 @@ class LlmConfig:
85
93
  session_title_prefix: str = "ai-push-hooks"
86
94
 
87
95
 
96
+ @dataclass(frozen=True)
97
+ class RunnerProfile:
98
+ type: str
99
+ name: str = ""
100
+ model: str | None = None
101
+ variant: str | None = None
102
+ project_access: str = "artifacts"
103
+ command: tuple[str, ...] = ()
104
+ prompt_transport: str = "stdin"
105
+
106
+
88
107
  @dataclass(frozen=True)
89
108
  class LoggingConfig:
90
109
  level: str = "status"
@@ -110,7 +129,13 @@ class StepConfig:
110
129
  allow_paths: tuple[str, ...] = ()
111
130
  executor: str | None = None
112
131
  assertion: str | None = None
132
+ python: str | None = None
133
+ options: dict[str, Any] = field(default_factory=dict)
134
+ command: tuple[str, ...] = ()
135
+ stdin: str | None = None
136
+ timeout_seconds: int | None = None
113
137
  when_env: str | None = None
138
+ runner: str | None = None
114
139
 
115
140
  @property
116
141
  def is_read_only(self) -> bool:
@@ -140,6 +165,7 @@ class HookConfig:
140
165
  logging: LoggingConfig
141
166
  workflow: WorkflowConfig
142
167
  modules: dict[str, ModuleConfig]
168
+ runners: dict[str, RunnerProfile] = field(default_factory=dict)
143
169
 
144
170
 
145
171
  @dataclass
@@ -180,7 +206,7 @@ class RuntimeContext:
180
206
  repo_root: pathlib.Path
181
207
  git_dir: pathlib.Path
182
208
  config: HookConfig
183
- logger: "HookLogger"
209
+ logger: HookLogger
184
210
  remote_name: str
185
211
  remote_url: str
186
212
  stdin_lines: list[str]
@@ -202,8 +228,14 @@ class HookLogger:
202
228
  console_level: str = "status"
203
229
  jsonl_write_failed: bool = False
204
230
  llm_calls: list[dict[str, Any]] = field(default_factory=list)
231
+ _lock: threading.RLock = field(
232
+ default_factory=threading.RLock,
233
+ init=False,
234
+ repr=False,
235
+ compare=False,
236
+ )
205
237
 
206
- _verbosity_order = {"status": 0, "info": 1, "debug": 2}
238
+ _verbosity_order: ClassVar[dict[str, int]] = {"status": 0, "info": 1, "debug": 2}
207
239
 
208
240
  def _level_is_enabled(self, level: str) -> bool:
209
241
  if level in {"warn", "error"}:
@@ -212,55 +244,247 @@ class HookLogger:
212
244
  required = self._verbosity_order.get(level, 0)
213
245
  return configured >= required
214
246
 
215
- def _emit(self, level: str, event: str, message: str, **fields: Any) -> None:
216
- if not self._level_is_enabled(level):
217
- return
218
- stamp = datetime.now(timezone.utc).isoformat()
219
- sys.stderr.write(f"[ai-push-hooks] {message}\n")
220
- if self.jsonl_path is None or self.jsonl_write_failed:
221
- return
222
- record = {"ts": stamp, "level": level, "event": event, "message": message, **fields}
247
+ @staticmethod
248
+ def _safe_text(value: object) -> str:
249
+ """Remove terminal escape sequences without changing ordinary text."""
250
+
251
+ return _UNSAFE_TERMINAL_CONTROL_PATTERN.sub(
252
+ "", _ANSI_ESCAPE_PATTERN.sub("", str(value))
253
+ )
254
+
255
+ @classmethod
256
+ def _safe_json_value(cls, value: Any) -> Any:
257
+ """Keep structured log values JSON-compatible and terminal-safe."""
258
+
259
+ if isinstance(value, str):
260
+ return cls._safe_text(value)
261
+ if isinstance(value, dict):
262
+ return {
263
+ cls._safe_text(key): cls._safe_json_value(item)
264
+ for key, item in value.items()
265
+ }
266
+ if isinstance(value, (list, tuple)):
267
+ return [cls._safe_json_value(item) for item in value]
268
+ if isinstance(value, (str, int, float, bool)) or value is None:
269
+ return value
270
+ return cls._safe_text(value)
271
+
272
+ @staticmethod
273
+ def _colors_enabled() -> bool:
274
+ """Resolve color policy at emission time so tests and embedding callers can override it."""
275
+
276
+ if "NO_COLOR" in os.environ:
277
+ return False
278
+ force_color = os.environ.get("FORCE_COLOR")
279
+ if force_color == "0":
280
+ return False
281
+ if force_color:
282
+ return True
283
+ if os.environ.get("TERM") == "dumb":
284
+ return False
223
285
  try:
286
+ return bool(sys.stderr.isatty())
287
+ except (AttributeError, OSError):
288
+ return False
289
+
290
+ @classmethod
291
+ def _style(cls, value: str, code: str, colors_enabled: bool) -> str:
292
+ if not colors_enabled:
293
+ return value
294
+ return f"\x1b[{code}m{value}\x1b[0m"
295
+
296
+ @classmethod
297
+ def _stage_for_console(cls, stage_name: object, colors_enabled: bool) -> str:
298
+ safe_stage = cls._safe_text(stage_name).replace("\n", "\\n").replace("\t", " ")
299
+ if not colors_enabled:
300
+ return safe_stage
301
+ if "." not in safe_stage:
302
+ return cls._style(safe_stage, "36", colors_enabled)
303
+ module, step = safe_stage.split(".", 1)
304
+ return (
305
+ cls._style(module, "36", colors_enabled)
306
+ + cls._style(".", "2", colors_enabled)
307
+ + cls._style(step, "35", colors_enabled)
308
+ )
309
+
310
+ @classmethod
311
+ def _semantic_body(
312
+ cls,
313
+ event: str,
314
+ message: str,
315
+ fields: dict[str, Any],
316
+ colors_enabled: bool,
317
+ level: str,
318
+ ) -> str:
319
+ safe_message = cls._safe_text(message).replace("\n", "\\n").replace("\t", " ")
320
+ if not colors_enabled:
321
+ return safe_message
322
+
323
+ if event == "llm.call" and {
324
+ "call_number",
325
+ "stage_name",
326
+ "purpose",
327
+ }.issubset(fields):
328
+ call_number = cls._safe_text(fields.get("call_number", ""))
329
+ stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
330
+ purpose = cls._style(
331
+ cls._safe_text(fields.get("purpose", "")).replace("\n", "\\n").replace("\t", " "),
332
+ "34",
333
+ colors_enabled,
334
+ )
335
+ return (
336
+ "LLM call "
337
+ + cls._style(f"#{call_number}", "1", colors_enabled)
338
+ + cls._style(":", "2", colors_enabled)
339
+ + " "
340
+ + stage
341
+ + cls._style(" - ", "2", colors_enabled)
342
+ + purpose
343
+ )
344
+
345
+ if event == "llm.complete" and {
346
+ "call_number",
347
+ "stage_name",
348
+ "runner_profile",
349
+ "runner_type",
350
+ }.issubset(fields):
351
+ call_number = cls._safe_text(fields.get("call_number", ""))
352
+ stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
353
+ profile = cls._style(
354
+ cls._safe_text(fields.get("runner_profile", "")).replace("\n", "\\n").replace("\t", " "),
355
+ "34",
356
+ colors_enabled,
357
+ )
358
+ runner_type = cls._style(
359
+ cls._safe_text(fields.get("runner_type", "")).replace("\n", "\\n").replace("\t", " "),
360
+ "34",
361
+ colors_enabled,
362
+ )
363
+ failed = bool(fields.get("failed", False)) or level == "error"
364
+ label = "LLM failed" if failed else "LLM complete"
365
+ label_color = "31" if failed else "32"
366
+ body = (
367
+ cls._style(label, label_color, colors_enabled)
368
+ + " "
369
+ + cls._style(f"#{call_number}", "1", colors_enabled)
370
+ + cls._style(":", "2", colors_enabled)
371
+ + " "
372
+ + stage
373
+ + cls._style(" (", "2", colors_enabled)
374
+ + profile
375
+ + cls._style("/", "2", colors_enabled)
376
+ + runner_type
377
+ + cls._style(")", "2", colors_enabled)
378
+ )
379
+ if "; " in safe_message:
380
+ body += cls._style("; " + safe_message.split("; ", 1)[1], "2", colors_enabled)
381
+ return body
382
+
383
+ return safe_message
384
+
385
+ @classmethod
386
+ def _console_prefix(cls, level: str, event: str, fields: dict[str, Any]) -> str:
387
+ prefix = "[ai-push-hooks]"
388
+ colors_enabled = cls._colors_enabled()
389
+ if not colors_enabled:
390
+ return prefix
391
+ color = {
392
+ "warn": "\x1b[33m",
393
+ "error": "\x1b[31m",
394
+ }.get(level, "\x1b[36m")
395
+ if event == "llm.complete" and {
396
+ "call_number",
397
+ "stage_name",
398
+ "runner_profile",
399
+ "runner_type",
400
+ }.issubset(fields):
401
+ color = (
402
+ "\x1b[31m"
403
+ if level == "error" or fields.get("failed", False)
404
+ else "\x1b[32m"
405
+ )
406
+ return f"{color}{prefix}\x1b[0m"
407
+
408
+ @classmethod
409
+ def _console_message(
410
+ cls,
411
+ level: str,
412
+ message: str,
413
+ *,
414
+ event: str = "",
415
+ fields: dict[str, Any] | None = None,
416
+ ) -> str:
417
+ # A log event is deliberately one physical line. This prevents an
418
+ # untrusted stage/profile/message from creating a fake prompt or log line.
419
+ safe_fields = fields or {}
420
+ colors_enabled = cls._colors_enabled()
421
+ body = cls._semantic_body(event, message, safe_fields, colors_enabled, level)
422
+ prefix = cls._console_prefix(level, event, safe_fields)
423
+ return f"{prefix} {body}\n"
424
+
425
+ def _emit(self, level: str, event: str, message: str, **fields: Any) -> None:
426
+ with self._lock:
427
+ if not self._level_is_enabled(level):
428
+ return
429
+ sys.stderr.write(
430
+ self._console_message(level, message, event=event, fields=fields)
431
+ )
432
+ if self.jsonl_path is None or self.jsonl_write_failed:
433
+ return
434
+ stamp = datetime.now(timezone.utc).isoformat()
435
+ safe_fields = self._safe_json_value(fields)
436
+ record = {
437
+ **safe_fields,
438
+ "ts": stamp,
439
+ "level": self._safe_text(level),
440
+ "event": self._safe_text(event),
441
+ "message": self._safe_text(message),
442
+ }
224
443
  try:
225
- initial_metadata = self.jsonl_path.lstat()
226
- except FileNotFoundError:
227
- initial_metadata = None
228
- if initial_metadata is not None:
229
- reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
230
- if stat.S_ISLNK(initial_metadata.st_mode) or bool(
231
- getattr(initial_metadata, "st_file_attributes", 0) & reparse_flag
232
- ):
233
- raise HookError(
234
- "JSONL log target must not be a symlink or reparse point: "
235
- f"{self.jsonl_path}"
444
+ try:
445
+ initial_metadata = self.jsonl_path.lstat()
446
+ except FileNotFoundError:
447
+ initial_metadata = None
448
+ if initial_metadata is not None:
449
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
450
+ if stat.S_ISLNK(initial_metadata.st_mode) or bool(
451
+ getattr(initial_metadata, "st_file_attributes", 0) & reparse_flag
452
+ ):
453
+ raise HookError(
454
+ "JSONL log target must not be a symlink or reparse point: "
455
+ f"{self.jsonl_path}"
456
+ )
457
+ flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
458
+ flags |= getattr(os, "O_NOFOLLOW", 0)
459
+ descriptor = os.open(self.jsonl_path, flags, 0o600)
460
+ try:
461
+ descriptor_metadata = os.fstat(descriptor)
462
+ path_metadata = self.jsonl_path.lstat()
463
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
464
+ if (
465
+ not stat.S_ISREG(descriptor_metadata.st_mode)
466
+ or stat.S_ISLNK(path_metadata.st_mode)
467
+ or bool(
468
+ getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
469
+ )
470
+ or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
471
+ != (path_metadata.st_dev, path_metadata.st_ino)
472
+ ):
473
+ raise HookError(f"JSONL log target is not a regular file: {self.jsonl_path}")
474
+ os.fchmod(descriptor, 0o600)
475
+ os.write(
476
+ descriptor,
477
+ (json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8"),
236
478
  )
237
- flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
238
- flags |= getattr(os, "O_NOFOLLOW", 0)
239
- descriptor = os.open(self.jsonl_path, flags, 0o600)
240
- try:
241
- descriptor_metadata = os.fstat(descriptor)
242
- path_metadata = self.jsonl_path.lstat()
243
- reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
244
- if (
245
- not stat.S_ISREG(descriptor_metadata.st_mode)
246
- or stat.S_ISLNK(path_metadata.st_mode)
247
- or bool(
248
- getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
479
+ finally:
480
+ os.close(descriptor)
481
+ except Exception as exc: # noqa: BLE001
482
+ self.jsonl_write_failed = True
483
+ sys.stderr.write(
484
+ self._console_message(
485
+ "error", f"JSONL logging disabled after write failure: {exc}"
249
486
  )
250
- or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
251
- != (path_metadata.st_dev, path_metadata.st_ino)
252
- ):
253
- raise HookError(f"JSONL log target is not a regular file: {self.jsonl_path}")
254
- os.fchmod(descriptor, 0o600)
255
- os.write(
256
- descriptor,
257
- (json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8"),
258
487
  )
259
- finally:
260
- os.close(descriptor)
261
- except Exception as exc: # noqa: BLE001
262
- self.jsonl_write_failed = True
263
- sys.stderr.write(f"[ai-push-hooks] JSONL logging disabled after write failure: {exc}\n")
264
488
 
265
489
  def debug(self, event: str, message: str, **fields: Any) -> None:
266
490
  self._emit("debug", event, message, **fields)
@@ -284,33 +508,141 @@ class HookLogger:
284
508
  model: str,
285
509
  attempt: int | None = None,
286
510
  total_attempts: int | None = None,
511
+ *,
512
+ runner_profile: str | None = None,
513
+ runner_type: str | None = None,
514
+ ) -> int:
515
+ with self._lock:
516
+ call_number = len(self.llm_calls) + 1
517
+ safe_stage = self._safe_text(stage_name)
518
+ safe_purpose = self._safe_text(purpose)
519
+ record: dict[str, Any] = {
520
+ "call_number": call_number,
521
+ "stage_name": safe_stage,
522
+ "purpose": safe_purpose,
523
+ "model": self._safe_text(model),
524
+ "module": safe_stage.split(".", 1)[0],
525
+ "step": safe_stage.split(".", 1)[1] if "." in safe_stage else safe_stage,
526
+ }
527
+ if attempt is not None:
528
+ record["attempt"] = attempt
529
+ if total_attempts is not None:
530
+ record["total_attempts"] = total_attempts
531
+ if runner_profile is not None:
532
+ record["runner_profile"] = self._safe_text(runner_profile)
533
+ if runner_type is not None:
534
+ record["runner_type"] = self._safe_text(runner_type)
535
+ self.llm_calls.append(record)
536
+ self.status(
537
+ "llm.call",
538
+ f"LLM call #{call_number}: {safe_stage} - {safe_purpose}",
539
+ **record,
540
+ )
541
+ return call_number
542
+
543
+ def llm_complete(
544
+ self,
545
+ call_number: int,
546
+ stage_name: str,
547
+ runner_profile: str,
548
+ runner_type: str,
549
+ *,
550
+ session_id: str | None = None,
551
+ session_state: str | None = None,
552
+ resumable: bool = False,
553
+ transcript: str | None = None,
554
+ resume_command: str | None = None,
555
+ failed: bool = False,
287
556
  ) -> None:
288
- call_number = len(self.llm_calls) + 1
289
- record: dict[str, Any] = {
557
+ """Record truthful completion/session details without inventing resume data."""
558
+
559
+ safe_stage = self._safe_text(stage_name)
560
+ safe_profile = self._safe_text(runner_profile)
561
+ safe_type = self._safe_text(runner_type)
562
+ safe_state = self._safe_text(session_state) if session_state is not None else None
563
+ safe_session_id = self._safe_text(session_id) if session_id is not None else None
564
+ safe_transcript = self._safe_text(transcript) if transcript is not None else None
565
+ safe_resume_command = (
566
+ self._safe_text(resume_command) if resume_command is not None else None
567
+ )
568
+ effective_resume_command = (
569
+ safe_resume_command
570
+ if safe_state == "persisted" and resumable
571
+ else None
572
+ )
573
+ session_details: list[str] = []
574
+ if safe_state == "persisted":
575
+ if safe_session_id:
576
+ session_details.append(f"session persisted: {safe_session_id}")
577
+ else:
578
+ session_details.append("session persisted")
579
+ if effective_resume_command:
580
+ session_details.append(f"resume: {effective_resume_command}")
581
+ elif not resumable:
582
+ session_details.append("not resumable")
583
+ if safe_transcript:
584
+ session_details.append(f"transcript: {safe_transcript}")
585
+ elif safe_state == "deleted":
586
+ if safe_session_id:
587
+ session_details.append(f"session deleted: {safe_session_id}")
588
+ else:
589
+ session_details.append("session deleted")
590
+ if safe_transcript:
591
+ session_details.append(f"transcript: {safe_transcript}")
592
+ elif safe_state == "ephemeral":
593
+ if safe_session_id:
594
+ session_details.append(f"session: {safe_session_id}")
595
+ session_details.append("not resumable")
596
+ elif safe_state is not None:
597
+ session_details.append(f"session {safe_state}")
598
+ elif safe_session_id:
599
+ session_details.append(f"session: {safe_session_id}")
600
+
601
+ message = (
602
+ f"LLM failed #{call_number}: {safe_stage} ({safe_profile}/{safe_type})"
603
+ if failed
604
+ else f"LLM complete #{call_number}: {safe_stage} ({safe_profile}/{safe_type})"
605
+ )
606
+ if session_details:
607
+ message += "; " + "; ".join(session_details)
608
+ fields: dict[str, Any] = {
290
609
  "call_number": call_number,
291
- "stage_name": stage_name,
292
- "purpose": purpose,
293
- "model": model,
610
+ "stage_name": safe_stage,
611
+ "module": safe_stage.split(".", 1)[0],
612
+ "step": safe_stage.split(".", 1)[1] if "." in safe_stage else safe_stage,
613
+ "runner_profile": safe_profile,
614
+ "runner_type": safe_type,
615
+ "failed": failed,
294
616
  }
295
- if attempt is not None:
296
- record["attempt"] = attempt
297
- if total_attempts is not None:
298
- record["total_attempts"] = total_attempts
299
- self.llm_calls.append(record)
300
- self.status(
301
- "llm.call",
302
- f"LLM call #{call_number}: {stage_name} - {purpose}",
303
- **record,
304
- )
617
+ if safe_session_id is not None:
618
+ fields["session_id"] = safe_session_id
619
+ if safe_state is not None:
620
+ fields["session_state"] = safe_state
621
+ if safe_transcript is not None:
622
+ fields["transcript"] = safe_transcript
623
+ if effective_resume_command is not None:
624
+ fields["resume_command"] = effective_resume_command
625
+ if any(
626
+ value is not None
627
+ for value in (
628
+ safe_session_id,
629
+ safe_state,
630
+ safe_transcript,
631
+ effective_resume_command,
632
+ )
633
+ ) or resumable:
634
+ fields["resumable"] = resumable
635
+ self.status("llm.complete", message, **fields)
305
636
 
306
637
  def llm_summary(self) -> None:
307
- stage_counts: dict[str, int] = {}
308
- for call in self.llm_calls:
309
- stage_name = str(call.get("stage_name", "")).strip() or "<unknown>"
310
- stage_counts[stage_name] = stage_counts.get(stage_name, 0) + 1
311
- self.status(
312
- "llm.calls_total",
313
- f"Total LLM calls this run: {len(self.llm_calls)}",
314
- total_calls=len(self.llm_calls),
315
- stage_counts=stage_counts,
316
- )
638
+ with self._lock:
639
+ stage_counts: dict[str, int] = {}
640
+ for call in self.llm_calls:
641
+ stage_name = str(call.get("stage_name", "")).strip() or "<unknown>"
642
+ stage_counts[stage_name] = stage_counts.get(stage_name, 0) + 1
643
+ self.status(
644
+ "llm.calls_total",
645
+ f"Total LLM calls this run: {len(self.llm_calls)}",
646
+ total_calls=len(self.llm_calls),
647
+ stage_counts=stage_counts,
648
+ )
@@ -0,0 +1,15 @@
1
+ # Bundled Python Dependency
2
+
3
+ The npm package includes the unmodified pure-Python wheel for [Tomli](https://pypi.org/project/tomli/2.4.0/). Python 3.10 uses it to parse TOML; Python 3.11+ uses the standard-library `tomllib` module.
4
+
5
+ The wrapper adds the wheel to `PYTHONPATH`, so no pip install, install script, or runtime download is needed. The wheel includes Tomli's MIT license under `tomli-2.4.0.dist-info/licenses/LICENSE`.
6
+
7
+ To reproduce the bundled download from the repository root:
8
+
9
+ ```bash
10
+ python -m pip download --index-url https://pypi.org/simple --require-hashes \
11
+ --only-binary=:all: --no-deps --platform any --implementation py --abi none \
12
+ --python-version 3.10 --dest vendor --requirement vendor/requirements.txt
13
+ ```
14
+
15
+ When updating, verify the pure-Python wheel's SHA-256 against PyPI, update the pin and wrapper path, and run the npm installed-hook tests on Python 3.10.
@@ -0,0 +1 @@
1
+ tomli==2.4.0 --hash=sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a