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.
- package/CHANGELOG.md +73 -1
- package/README.md +80 -525
- package/SECURITY.md +102 -14
- package/ai-push-hooks.toml +9 -2
- package/bin/ai-push-hooks.js +6 -6
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -13
- package/src/ai_push_hooks/config.py +575 -22
- package/src/ai_push_hooks/engine.py +116 -7
- package/src/ai_push_hooks/executors/apply.py +75 -36
- package/src/ai_push_hooks/executors/ask.py +224 -0
- package/src/ai_push_hooks/executors/exec.py +17 -801
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
- package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
- package/src/ai_push_hooks/executors/runners/process.py +464 -0
- package/src/ai_push_hooks/executors/runners/registry.py +117 -0
- package/src/ai_push_hooks/executors/step_commands.py +478 -0
- package/src/ai_push_hooks/git_utils.py +834 -0
- package/src/ai_push_hooks/hook.py +1 -1
- package/src/ai_push_hooks/modules/beads.py +1 -1
- package/src/ai_push_hooks/modules/docs.py +129 -89
- package/src/ai_push_hooks/modules/pr.py +1 -1
- package/src/ai_push_hooks/plugin_loader.py +422 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +407 -75
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- 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", "
|
|
13
|
-
PROMPTABLE_STEP_TYPES = frozenset({"
|
|
14
|
-
SUPPORTED_STEP_TYPES = frozenset({"collect", "
|
|
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:
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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":
|
|
292
|
-
"
|
|
293
|
-
"
|
|
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
|
|
296
|
-
|
|
297
|
-
if
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
+
)
|
package/vendor/README.md
ADDED
|
@@ -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
|
|
Binary file
|