ai-push-hooks 0.2.0 → 0.3.0

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