luckyd-code 1.2.2__py3-none-any.whl

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 (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,157 @@
1
+ """Auto-commit — stage and commit agent-modified files after each completed turn.
2
+
3
+ Behaviour
4
+ ---------
5
+ * Only runs when inside a git repository.
6
+ * Only commits files that the agent actually wrote or edited this turn.
7
+ * Skips the commit if there are no staged changes (e.g. the write was a no-op).
8
+ * Commit message is derived from the user's prompt (first 72 chars) so the
9
+ git log stays readable — same approach as Aider.
10
+ * Can be disabled globally with: /config set auto_commit false
11
+ """
12
+
13
+ import subprocess
14
+
15
+ from ..log import get_logger
16
+
17
+ # Tool names whose arguments contain the path of a file that was changed.
18
+ # The argument key that holds the path is listed alongside.
19
+ _WRITE_TOOLS: dict[str, str] = {
20
+ "Write": "file_path",
21
+ "Edit": "file_path",
22
+ "MultiEdit": "file_path",
23
+ }
24
+
25
+ _logger = get_logger()
26
+
27
+
28
+ def _in_git_repo(cwd: str | None = None) -> bool:
29
+ """Return True if cwd (or the process cwd) is inside a git repository."""
30
+ try:
31
+ r = subprocess.run(
32
+ ["git", "rev-parse", "--is-inside-work-tree"],
33
+ capture_output=True, text=True, timeout=5,
34
+ cwd=cwd,
35
+ )
36
+ return r.returncode == 0
37
+ except Exception:
38
+ return False
39
+
40
+
41
+ def _stage_files(paths: list[str], cwd: str | None = None) -> bool:
42
+ """Stage specific files. Returns True if at least one file was staged."""
43
+ if not paths:
44
+ return False
45
+ try:
46
+ r = subprocess.run(
47
+ ["git", "add", "--"] + paths,
48
+ capture_output=True, text=True, timeout=10,
49
+ cwd=cwd,
50
+ )
51
+ if r.returncode != 0:
52
+ _logger.warning("git add failed: %s", r.stderr.strip())
53
+ return False
54
+ return True
55
+ except Exception as e:
56
+ _logger.warning("git add error: %s", e)
57
+ return False
58
+
59
+
60
+ def _has_staged_changes(cwd: str | None = None) -> bool:
61
+ """Return True if there is anything in the index to commit."""
62
+ try:
63
+ r = subprocess.run(
64
+ ["git", "diff", "--cached", "--quiet"],
65
+ capture_output=True, timeout=5,
66
+ cwd=cwd,
67
+ )
68
+ # exit 1 means differences exist
69
+ return r.returncode == 1
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def _make_commit_message(user_prompt: str) -> str:
75
+ """Build a short, readable commit message from the user's prompt."""
76
+ first_line = user_prompt.strip().splitlines()[0] if user_prompt.strip() else "agent changes"
77
+ # Truncate to 72 chars (git convention)
78
+ subject = first_line[:72].rstrip()
79
+ return f"agent: {subject}"
80
+
81
+
82
+ def _commit(message: str, cwd: str | None = None) -> str | None:
83
+ """Create a commit. Returns the short SHA on success, None on failure."""
84
+ try:
85
+ r = subprocess.run(
86
+ ["git", "commit", "-m", message],
87
+ capture_output=True, text=True, timeout=15,
88
+ cwd=cwd,
89
+ )
90
+ if r.returncode != 0:
91
+ _logger.warning("git commit failed: %s", r.stderr.strip())
92
+ return None
93
+ # Parse the short SHA from the output line like "[main abc1234] ..."
94
+ for line in r.stdout.splitlines():
95
+ if line.startswith("["):
96
+ parts = line.split()
97
+ if len(parts) >= 2:
98
+ return parts[1].rstrip("]")
99
+ return "ok"
100
+ except Exception as e:
101
+ _logger.warning("git commit error: %s", e)
102
+ return None
103
+
104
+
105
+ def collect_modified_paths(tool_calls: list[dict], tool_args_map: dict[str, dict]) -> list[str]:
106
+ """Extract file paths that were written or edited this turn.
107
+
108
+ Args:
109
+ tool_calls: List of tool-call dicts (id, function.name, function.arguments).
110
+ tool_args_map: Mapping of tool_call_id → parsed args dict (populated during execution).
111
+
112
+ Returns:
113
+ Deduplicated list of absolute/relative file path strings.
114
+ """
115
+ seen: set[str] = set()
116
+ paths: list[str] = []
117
+ for tc in tool_calls:
118
+ name = tc.get("function", {}).get("name", "")
119
+ arg_key = _WRITE_TOOLS.get(name)
120
+ if not arg_key:
121
+ continue
122
+ args = tool_args_map.get(tc.get("id", ""), {})
123
+ fp = args.get(arg_key, "")
124
+ if fp and fp not in seen:
125
+ seen.add(fp)
126
+ paths.append(fp)
127
+ return paths
128
+
129
+
130
+ def auto_commit(
131
+ user_prompt: str,
132
+ modified_paths: list[str],
133
+ cwd: str | None = None,
134
+ enabled: bool = True,
135
+ ) -> str | None:
136
+ """Stage modified_paths and commit if inside a git repo and enabled.
137
+
138
+ Returns the short commit SHA on success, None if skipped or failed.
139
+ """
140
+ if not enabled or not modified_paths:
141
+ return None
142
+
143
+ if not _in_git_repo(cwd):
144
+ return None
145
+
146
+ # Only stage the files the agent actually touched
147
+ if not _stage_files(modified_paths, cwd):
148
+ return None
149
+
150
+ if not _has_staged_changes(cwd):
151
+ return None
152
+
153
+ message = _make_commit_message(user_prompt)
154
+ sha = _commit(message, cwd)
155
+ if sha:
156
+ _logger.info("Auto-committed %d file(s): %s [%s]", len(modified_paths), message, sha)
157
+ return sha
@@ -0,0 +1,85 @@
1
+ import subprocess
2
+
3
+
4
+ def git_status() -> str:
5
+ try:
6
+ r = subprocess.run(["git", "status"], capture_output=True, text=True, timeout=30)
7
+ return r.stdout.strip() or r.stderr.strip()
8
+ except Exception as e:
9
+ return f"Error: {e}"
10
+
11
+
12
+ def git_diff(staged: bool = False) -> str:
13
+ try:
14
+ cmd = ["git", "diff", "--cached"] if staged else ["git", "diff"]
15
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
16
+ out = r.stdout.strip()
17
+ if not out:
18
+ return "No changes"
19
+ return out[:5000]
20
+ except Exception as e:
21
+ return f"Error: {e}"
22
+
23
+
24
+ def git_log(count: int = 10) -> str:
25
+ try:
26
+ r = subprocess.run(
27
+ ["git", "log", f"--max-count={count}", "--oneline"],
28
+ capture_output=True, text=True, timeout=30,
29
+ )
30
+ return r.stdout.strip() or r.stderr.strip()
31
+ except Exception as e:
32
+ return f"Error: {e}"
33
+
34
+
35
+ def git_commit(message: str) -> str:
36
+ try:
37
+ r = subprocess.run(
38
+ ["git", "commit", "-m", message],
39
+ capture_output=True, text=True, timeout=30,
40
+ )
41
+ return r.stdout.strip() or r.stderr.strip()
42
+ except Exception as e:
43
+ return f"Error: {e}"
44
+
45
+
46
+ def git_add(files: list[str] | None = None) -> str:
47
+ try:
48
+ cmd = ["git", "add"] + (files if files else ["-A"])
49
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
50
+ return r.stdout.strip() or r.stderr.strip() or "Staged"
51
+ except Exception as e:
52
+ return f"Error: {e}"
53
+
54
+
55
+ def git_branch() -> str:
56
+ try:
57
+ r = subprocess.run(
58
+ ["git", "branch", "-a"],
59
+ capture_output=True, text=True, timeout=30,
60
+ )
61
+ return r.stdout.strip() or r.stderr.strip()
62
+ except Exception as e:
63
+ return f"Error: {e}"
64
+
65
+
66
+ def git_create_pr(title: str, body: str = "", draft: bool = True) -> str:
67
+ try:
68
+ cmd = ["gh", "pr", "create", "--title", title, "--body", body]
69
+ if draft:
70
+ cmd.append("--draft")
71
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
72
+ return r.stdout.strip() or r.stderr.strip()
73
+ except Exception as e:
74
+ return f"Error: {e}"
75
+
76
+
77
+ def git_push(branch: str | None = None) -> str:
78
+ try:
79
+ cmd = ["git", "push", "-u", "origin"]
80
+ if branch:
81
+ cmd.append(branch)
82
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
83
+ return r.stdout.strip() or r.stderr.strip()
84
+ except Exception as e:
85
+ return f"Error: {e}"
luckyd_code/hooks.py ADDED
@@ -0,0 +1,236 @@
1
+ """Lifecycle hooks system — pre/post tool use, pre/post chat, session events.
2
+
3
+ Hooks are shell scripts defined in .deepseek-code/settings.local.json:
4
+
5
+ {
6
+ "hooks": {
7
+ "preToolUse": {
8
+ "script": "echo 'About to run $DSC_TOOL_NAME'",
9
+ "tools": ["all"]
10
+ },
11
+ "postToolUse": "npm run lint-changed",
12
+ "preChat": "echo 'Sending request to $DSC_MODEL'",
13
+ "postChat": "echo 'Response received'",
14
+ "onSessionStart": "echo 'Session started at $DSC_TIME'",
15
+ "onSessionEnd": "echo 'Session ended'"
16
+ }
17
+ }
18
+
19
+ Hooks can return JSON on their first line to control execution:
20
+ {"allow": false} — block the tool call (preToolUse only)
21
+ {"env": {"K": "v"}} — update environment variables
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from .settings import load_settings, get_settings_dir
33
+
34
+
35
+ HOOK_EVENTS = [
36
+ "preToolUse",
37
+ "postToolUse",
38
+ "preChat",
39
+ "postChat",
40
+ "onSessionStart",
41
+ "onSessionEnd",
42
+ ]
43
+
44
+
45
+ @dataclass
46
+ class HookResult:
47
+ success: bool = True
48
+ output: str = ""
49
+ error: Optional[str] = None
50
+ allow: bool = True # preToolUse: block or allow the tool call
51
+ env_updates: dict = field(default_factory=dict)
52
+
53
+
54
+ class HookRunner:
55
+ """Execute shell-based hooks for lifecycle events."""
56
+
57
+ def __init__(self):
58
+ self.settings = load_settings()
59
+
60
+ def run_hook(self, event: str, context: Optional[dict] = None) -> list[HookResult]:
61
+ """Run all hooks configured for an event.
62
+
63
+ Args:
64
+ event: One of HOOK_EVENTS.
65
+ context: Dict of extra env vars to pass (e.g. tool_name, tool_args).
66
+
67
+ Returns:
68
+ List of HookResult, one per hook script.
69
+ """
70
+ if event not in HOOK_EVENTS:
71
+ return [HookResult(success=False, error=f"Unknown hook event: {event}")]
72
+
73
+ scripts = self._get_hook_scripts(event)
74
+ results = []
75
+ for hook_cfg in scripts:
76
+ script = hook_cfg.get("script", "")
77
+ if not script:
78
+ continue
79
+ # Check tool filter if present
80
+ tool_filter = hook_cfg.get("tools", ["all"])
81
+ if "all" not in tool_filter:
82
+ tool_name = (context or {}).get("tool_name", "")
83
+ if tool_name not in tool_filter:
84
+ continue
85
+ result = self._execute_script(script, event, context)
86
+ results.append(result)
87
+ return results
88
+
89
+ def _get_hook_scripts(self, event: str) -> list[dict]:
90
+ """Get all hook scripts for an event from settings.
91
+
92
+ Returns empty for unknown (not in HOOK_EVENTS) event types
93
+ even if hooks are configured — prevents misconfiguration.
94
+ """
95
+ if event not in HOOK_EVENTS:
96
+ return []
97
+ hooks = self.settings.get("hooks", {})
98
+ raw = hooks.get(event, "")
99
+ if isinstance(raw, str):
100
+ return [{"script": raw, "tools": ["all"]}] if raw else []
101
+ elif isinstance(raw, dict):
102
+ # Single hook config
103
+ if "script" in raw:
104
+ return [raw]
105
+ # Multiple hook configs keyed by name
106
+ return list(raw.values())
107
+ elif isinstance(raw, list):
108
+ return raw
109
+ return []
110
+
111
+ def _execute_script(self, script: str, event: str,
112
+ context: Optional[dict] = None) -> HookResult:
113
+ """Execute a single hook script and return the result.
114
+
115
+ Supports both shell commands and ``.py`` scripts. Python scripts
116
+ are launched via ``sys.executable`` with the same environment
117
+ variables, so they work portably across platforms.
118
+ """
119
+ env = {
120
+ "DSC_HOOK_EVENT": event,
121
+ "DSC_PROJECT_DIR": str(get_settings_dir().parent),
122
+ "DSC_TIME": __import__("datetime").datetime.now().isoformat(),
123
+ }
124
+ if context:
125
+ for k, v in context.items():
126
+ env[f"DSC_{k.upper()}"] = str(v)
127
+
128
+ full_env = {**os.environ, **env}
129
+
130
+ # Detect Python hook scripts
131
+ script_path = Path(script)
132
+ if script_path.suffix == ".py":
133
+ return self._run_python_script(script_path, event, full_env)
134
+
135
+ try:
136
+ proc = subprocess.run(
137
+ script,
138
+ shell=True,
139
+ capture_output=True,
140
+ text=True,
141
+ timeout=30,
142
+ env=full_env,
143
+ )
144
+ output = proc.stdout.strip()
145
+ if proc.returncode != 0 and proc.stderr.strip():
146
+ return HookResult(
147
+ success=False,
148
+ error=proc.stderr.strip(),
149
+ output=output,
150
+ )
151
+
152
+ # Parse optional JSON directive from first line
153
+ if output:
154
+ first_line = output.split("\n")[0]
155
+ if first_line.startswith("{"):
156
+ try:
157
+ data = json.loads(first_line)
158
+ output = "\n".join(output.split("\n")[1:])
159
+ return HookResult(
160
+ success=True,
161
+ output=output,
162
+ allow=data.get("allow", True),
163
+ env_updates=data.get("env", {}),
164
+ )
165
+ except json.JSONDecodeError:
166
+ pass
167
+
168
+ return HookResult(success=True, output=output)
169
+
170
+ except subprocess.TimeoutExpired:
171
+ return HookResult(success=False, error=f"Hook '{event}' timed out after 30s")
172
+ except FileNotFoundError:
173
+ return HookResult(success=False, error=f"Hook '{event}' command not found: {script[:100]}")
174
+ except Exception as e:
175
+ return HookResult(success=False, error=f"Hook '{event}' error: {e}")
176
+
177
+ def _run_python_script(self, script_path: Path, event: str,
178
+ full_env: dict) -> HookResult:
179
+ """Execute a ``.py`` hook script with the current Python interpreter.
180
+
181
+ The script receives all ``DSC_*`` environment variables and can
182
+ return JSON on its first stdout line for execution control
183
+ (same protocol as shell hooks).
184
+ """
185
+ # Merge with os.environ so the child process inherits essential
186
+ # system variables (PATH, SYSTEMROOT, etc.) – required on Windows.
187
+ merged_env = {**os.environ, **full_env}
188
+ try:
189
+ proc = subprocess.run(
190
+ [sys.executable, str(script_path)],
191
+ capture_output=True,
192
+ text=True,
193
+ timeout=30,
194
+ env=merged_env,
195
+ )
196
+ output = proc.stdout.strip()
197
+ if proc.returncode != 0 and proc.stderr.strip():
198
+ return HookResult(
199
+ success=False,
200
+ error=proc.stderr.strip(),
201
+ output=output,
202
+ )
203
+
204
+ # Parse optional JSON directive from first line
205
+ if output:
206
+ first_line = output.split("\n")[0]
207
+ if first_line.startswith("{"):
208
+ try:
209
+ data = json.loads(first_line)
210
+ output = "\n".join(output.split("\n")[1:])
211
+ return HookResult(
212
+ success=True,
213
+ output=output,
214
+ allow=data.get("allow", True),
215
+ env_updates=data.get("env", {}),
216
+ )
217
+ except json.JSONDecodeError:
218
+ pass
219
+
220
+ return HookResult(success=True, output=output)
221
+
222
+ except subprocess.TimeoutExpired:
223
+ return HookResult(success=False, error=f"Python hook '{script_path.name}' timed out after 30s")
224
+ except Exception as e:
225
+ return HookResult(success=False, error=f"Python hook '{script_path.name}' error: {e}")
226
+
227
+
228
+ # Global singleton access
229
+ _hook_runner: Optional[HookRunner] = None
230
+
231
+
232
+ def get_hook_runner() -> HookRunner:
233
+ global _hook_runner
234
+ if _hook_runner is None:
235
+ _hook_runner = HookRunner()
236
+ return _hook_runner