claude-dev-env 1.30.1 → 1.31.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.
Files changed (40) hide show
  1. package/agents/clean-coder.md +275 -111
  2. package/agents/code-quality-agent.md +196 -209
  3. package/bin/install.mjs +81 -0
  4. package/bin/install.test.mjs +158 -0
  5. package/bin/install_mypy_ini.mjs +51 -0
  6. package/bin/install_mypy_ini.test.mjs +121 -0
  7. package/commands/hook-log-extract.md +70 -0
  8. package/commands/hook-log-init.md +76 -0
  9. package/hooks/blocking/code_rules_enforcer.py +5 -3
  10. package/hooks/blocking/destructive_command_blocker.py +187 -0
  11. package/hooks/blocking/question_to_user_enforcer.py +140 -0
  12. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
  13. package/hooks/blocking/test_destructive_command_blocker.py +397 -0
  14. package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
  15. package/hooks/config/hook_log_extractor_constants.py +221 -0
  16. package/hooks/config/messages.py +3 -0
  17. package/hooks/config/test_hook_log_extractor_constants.py +96 -0
  18. package/hooks/config/test_messages.py +5 -0
  19. package/hooks/diagnostic/hook_log_extractor.py +907 -0
  20. package/hooks/diagnostic/hook_log_init.py +202 -0
  21. package/hooks/diagnostic/hook_log_stop_wrapper.py +84 -0
  22. package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
  23. package/hooks/diagnostic/migrations/README.md +77 -0
  24. package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
  25. package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
  26. package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
  27. package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
  28. package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
  29. package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
  30. package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
  31. package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
  32. package/hooks/diagnostic/schema.sql +51 -0
  33. package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
  34. package/hooks/diagnostic/test_hook_log_init.py +227 -0
  35. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +98 -0
  36. package/hooks/hooks.json +10 -0
  37. package/package.json +1 -1
  38. package/rules/ask-user-question-required.md +44 -0
  39. package/scripts/config/test_spec_implementer_prompt.py +0 -4
  40. package/scripts/test_groq_bugteam_spec.py +0 -8
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """Initialize the Neon schema for hook-log diagnostics.
3
+
4
+ Verifies required environment variables, opens a psycopg connection,
5
+ applies the idempotent DDL from ``schema.sql``, runs a sentinel
6
+ insert/select/delete round-trip to prove read-write parity, and prints
7
+ a success report with the Neon host, table name, and row count.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+ from urllib.parse import urlparse
16
+
17
+ if str(Path(__file__).resolve().parent.parent) not in sys.path:
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
19
+
20
+ try:
21
+ import psycopg
22
+ except ImportError:
23
+ psycopg = None
24
+
25
+ from config.hook_log_extractor_constants import (
26
+ CONNECT_TIMEOUT_SECONDS,
27
+ EXIT_CODE_ENVIRONMENT_MISSING,
28
+ EXIT_CODE_SUCCESS,
29
+ HOOK_EVENTS_ROW_COUNT_SQL,
30
+ HOOK_EVENTS_TABLE_NAME,
31
+ MISSING_ENVIRONMENT_VARIABLE_PREFIX,
32
+ MISSING_PSYCOPG_WARNING_LABEL,
33
+ NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
34
+ NEON_HOST_REPORT_LABEL,
35
+ OUTCOME_INIT_PROBE,
36
+ ROW_COUNT_REPORT_LABEL,
37
+ SCHEMA_RELATIVE_PATH,
38
+ SEMICOLON_SPLIT_TOKEN,
39
+ SENTINEL_DELETE_SQL,
40
+ SENTINEL_HOOK_CATEGORY,
41
+ SENTINEL_HOOK_EVENT,
42
+ SENTINEL_HOOK_NAME,
43
+ SENTINEL_INSERT_FAILURE_MESSAGE,
44
+ SENTINEL_INSERT_SQL,
45
+ SENTINEL_SELECT_FAILURE_MESSAGE,
46
+ SENTINEL_SELECT_SQL,
47
+ SENTINEL_SESSION_ID,
48
+ SENTINEL_SOURCE_LINE_NUMBER,
49
+ SENTINEL_SOURCE_PATH,
50
+ SUCCESS_REPORT_HEADER,
51
+ TABLE_REPORT_LABEL,
52
+ UNKNOWN_HOST_PLACEHOLDER,
53
+ )
54
+
55
+
56
+ class MissingPsycopgDependencyError(RuntimeError):
57
+ """Raised when the psycopg driver is not installed in the interpreter."""
58
+
59
+
60
+ def verify_environment_variables() -> list[str]:
61
+ """Return names of required env vars that are unset; empty list when all present.
62
+
63
+ Only ``NEON_HOOK_LOGS_DATABASE_URL`` is verified here. ``bws run``
64
+ intentionally strips ``BWS_ACCESS_TOKEN`` from the child environment
65
+ to prevent subprocess credential leakage; checking for it inside a
66
+ child process invoked via ``bws run -- python hook_log_init.py``
67
+ would therefore always fail even when the machine is configured
68
+ correctly. The one-time ``setx BWS_ACCESS_TOKEN`` prerequisite is
69
+ documented in ``packages/claude-dev-env/commands/hook-log-init.md``.
70
+ """
71
+ all_missing_variable_names: list[str] = []
72
+ raw_database_url_value = os.environ.get(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE)
73
+ if raw_database_url_value is None or raw_database_url_value.strip() == "":
74
+ all_missing_variable_names.append(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE)
75
+ return all_missing_variable_names
76
+
77
+
78
+ def _schema_file_path() -> Path:
79
+ """Return the absolute path to the companion ``schema.sql`` file."""
80
+ return Path(__file__).resolve().parent / SCHEMA_RELATIVE_PATH
81
+
82
+
83
+ def _split_ddl_statements(schema_text: str) -> list[str]:
84
+ """Split a DDL file on semicolons and drop empty trailing fragments."""
85
+ all_raw_fragments = schema_text.split(SEMICOLON_SPLIT_TOKEN)
86
+ all_trimmed_statements = [
87
+ each_fragment.strip() for each_fragment in all_raw_fragments
88
+ ]
89
+ return [
90
+ each_statement for each_statement in all_trimmed_statements if each_statement
91
+ ]
92
+
93
+
94
+ def apply_schema(neon_connection: object) -> None:
95
+ """Execute the idempotent DDL from ``schema.sql`` on the given connection."""
96
+ schema_text = _schema_file_path().read_text(encoding="utf-8")
97
+ all_ddl_statements = _split_ddl_statements(schema_text)
98
+ with neon_connection.cursor() as neon_cursor:
99
+ for each_statement in all_ddl_statements:
100
+ neon_cursor.execute(each_statement)
101
+ neon_connection.commit()
102
+
103
+
104
+ def run_sentinel_round_trip(neon_connection: object) -> None:
105
+ """Insert a sentinel row, select it back by id, verify it, then delete it."""
106
+ with neon_connection.cursor() as neon_cursor:
107
+ neon_cursor.execute(
108
+ SENTINEL_INSERT_SQL,
109
+ (
110
+ SENTINEL_SESSION_ID,
111
+ SENTINEL_HOOK_EVENT,
112
+ SENTINEL_HOOK_NAME,
113
+ SENTINEL_HOOK_CATEGORY,
114
+ OUTCOME_INIT_PROBE,
115
+ SENTINEL_SOURCE_PATH,
116
+ SENTINEL_SOURCE_LINE_NUMBER,
117
+ ),
118
+ )
119
+ sentinel_row = neon_cursor.fetchone()
120
+ if sentinel_row is None:
121
+ raise RuntimeError(SENTINEL_INSERT_FAILURE_MESSAGE)
122
+ sentinel_id = sentinel_row[0]
123
+ neon_cursor.execute(SENTINEL_SELECT_SQL, (sentinel_id,))
124
+ fetched_row = neon_cursor.fetchone()
125
+ if fetched_row is None or fetched_row[0] != sentinel_id:
126
+ raise RuntimeError(SENTINEL_SELECT_FAILURE_MESSAGE)
127
+ neon_cursor.execute(SENTINEL_DELETE_SQL, (sentinel_id,))
128
+ neon_connection.commit()
129
+
130
+
131
+ def _extract_host_from_database_url(database_url: str) -> str:
132
+ """Return the hostname portion of a Postgres connection URL."""
133
+ parsed_url = urlparse(database_url)
134
+ return parsed_url.hostname or UNKNOWN_HOST_PLACEHOLDER
135
+
136
+
137
+ def print_success_report(neon_host: str, table_name: str, row_count: int) -> None:
138
+ """Print a 4-line success report with Neon host, table name, and row count."""
139
+ print(SUCCESS_REPORT_HEADER)
140
+ print(f"{NEON_HOST_REPORT_LABEL} {neon_host}")
141
+ print(f"{TABLE_REPORT_LABEL} {table_name}")
142
+ print(f"{ROW_COUNT_REPORT_LABEL} {row_count}")
143
+
144
+
145
+ def connect_to_neon() -> object:
146
+ """Open a psycopg v3 connection using the configured Neon database URL.
147
+
148
+ Raises ``MissingPsycopgDependencyError`` when psycopg is not installed
149
+ so the caller can surface a clear actionable message.
150
+ """
151
+ if psycopg is None:
152
+ raise MissingPsycopgDependencyError(MISSING_PSYCOPG_WARNING_LABEL)
153
+ database_url = os.environ[NEON_DATABASE_URL_ENVIRONMENT_VARIABLE]
154
+ return psycopg.connect(database_url, connect_timeout=CONNECT_TIMEOUT_SECONDS)
155
+
156
+
157
+ def _fetch_row_count(neon_connection: object) -> int:
158
+ """Return the current row count of the ``hook_events`` table."""
159
+ with neon_connection.cursor() as neon_cursor:
160
+ neon_cursor.execute(HOOK_EVENTS_ROW_COUNT_SQL)
161
+ row_count_row = neon_cursor.fetchone()
162
+ return int(row_count_row[0]) if row_count_row else 0
163
+
164
+
165
+ def _print_missing_environment_variables(all_missing_variable_names: list[str]) -> None:
166
+ """Write one stderr line per missing environment variable name."""
167
+ for each_missing_name in all_missing_variable_names:
168
+ print(
169
+ f"{MISSING_ENVIRONMENT_VARIABLE_PREFIX}{each_missing_name}",
170
+ file=sys.stderr,
171
+ )
172
+
173
+
174
+ def main() -> int:
175
+ """Entry point for the ``/hook-log-init`` slash command."""
176
+ all_missing_variable_names = verify_environment_variables()
177
+ if all_missing_variable_names:
178
+ _print_missing_environment_variables(all_missing_variable_names)
179
+ return EXIT_CODE_ENVIRONMENT_MISSING
180
+ neon_connection = connect_to_neon()
181
+ try:
182
+ apply_schema(neon_connection)
183
+ run_sentinel_round_trip(neon_connection)
184
+ row_count_value = _fetch_row_count(neon_connection)
185
+ neon_host_string = _extract_host_from_database_url(
186
+ os.environ[NEON_DATABASE_URL_ENVIRONMENT_VARIABLE],
187
+ )
188
+ print_success_report(
189
+ neon_host=neon_host_string,
190
+ table_name=HOOK_EVENTS_TABLE_NAME,
191
+ row_count=row_count_value,
192
+ )
193
+ return EXIT_CODE_SUCCESS
194
+ finally:
195
+ try:
196
+ neon_connection.close()
197
+ except Exception:
198
+ pass
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Stop-hook wrapper for hook_log_extractor that never surfaces a hook failure.
3
+
4
+ Invokes ``hook_log_extractor.py --incremental`` via ``bws run`` only when
5
+ both ``bws`` is on PATH and ``BWS_ACCESS_TOKEN`` is set; otherwise falls
6
+ through to run the extractor directly. The extractor itself exits 0 when
7
+ ``NEON_HOOK_LOGS_DATABASE_URL`` is unset, so the wrapper can rely on that
8
+ offline-graceful path. This wrapper always exits 0 so the Stop hook
9
+ never blocks session end on a missing dependency.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ if str(Path(__file__).resolve().parent.parent) not in sys.path:
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from config.hook_log_extractor_constants import (
24
+ BWS_ACCESS_TOKEN_ENV_VAR,
25
+ BWS_EXECUTABLE_NAME,
26
+ BWS_RUN_SEPARATOR,
27
+ BWS_RUN_SUBCOMMAND,
28
+ EXIT_CODE_SUCCESS,
29
+ FLAG_INCREMENTAL,
30
+ STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME,
31
+ )
32
+
33
+
34
+ def _extractor_script_path() -> str:
35
+ return str(
36
+ Path(__file__).resolve().parent / STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME
37
+ )
38
+
39
+
40
+ def _can_use_bws() -> bool:
41
+ if not os.environ.get(BWS_ACCESS_TOKEN_ENV_VAR):
42
+ return False
43
+ return shutil.which(BWS_EXECUTABLE_NAME) is not None
44
+
45
+
46
+ def _run_with_bws() -> None:
47
+ subprocess.run(
48
+ [
49
+ BWS_EXECUTABLE_NAME,
50
+ BWS_RUN_SUBCOMMAND,
51
+ BWS_RUN_SEPARATOR,
52
+ sys.executable,
53
+ _extractor_script_path(),
54
+ FLAG_INCREMENTAL,
55
+ ],
56
+ check=False,
57
+ )
58
+
59
+
60
+ def _run_without_bws() -> None:
61
+ subprocess.run(
62
+ [
63
+ sys.executable,
64
+ _extractor_script_path(),
65
+ FLAG_INCREMENTAL,
66
+ ],
67
+ check=False,
68
+ )
69
+
70
+
71
+ def main() -> int:
72
+ """Invoke the extractor with or without bws; swallow all failures."""
73
+ try:
74
+ if _can_use_bws():
75
+ _run_with_bws()
76
+ else:
77
+ _run_without_bws()
78
+ except Exception:
79
+ pass
80
+ return EXIT_CODE_SUCCESS
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ DROP VIEW IF EXISTS blocked_commands;
2
+
3
+ DROP TABLE IF EXISTS hook_events;
@@ -0,0 +1,77 @@
1
+ # Hook-Log Diagnostic Migrations
2
+
3
+ One-time DDL migrations for the hook-log diagnostic store. Each file is named
4
+ `YYYY-MM-DD-<short-description>.sql` and contains idempotent statements that
5
+ can be re-run without error.
6
+
7
+ These files are records of operations already executed (or to be executed)
8
+ against a specific Neon project. They are not run automatically by any hook.
9
+
10
+ ## How to apply
11
+
12
+ A migration runs against the Postgres connection URL of the project being
13
+ modified. The runner is whichever client the operator prefers; a typical
14
+ pattern is:
15
+
16
+ ```
17
+ psql "$DATABASE_URL" -f packages/claude-dev-env/hooks/diagnostic/migrations/<file>.sql
18
+ ```
19
+
20
+ When the project is hosted on Neon, the `mcp__neon__run_sql_transaction` tool
21
+ accepts the same statements as a list of strings. That is the path used for
22
+ the 2026-04-25 isolation migration described below, since the operator
23
+ already has Neon MCP authenticated.
24
+
25
+ ## 2026-04-25-drop-themes-hook-events.sql
26
+
27
+ **Target:** Neon project `still-dust-13937951` ("Themes"). This is the
28
+ production themes-asset-database project, which up to PR #257 was also
29
+ holding the hook-log diagnostic table because the
30
+ `NEON_HOOK_LOGS_DATABASE_URL` Bitwarden secret was pointing at it.
31
+
32
+ **Effect:** Drops the diagnostic view `blocked_commands` and the diagnostic
33
+ table `hook_events` from the Themes project. The two objects were created
34
+ by `schema.sql` during a misrouted live test in session 80; they share no
35
+ foreign keys or other coupling with the themes-domain tables (`assets`,
36
+ `themes`, `monthly_sales`, etc.) and removing them is a pure cleanup.
37
+
38
+ **State before this migration:** `hook_events` holds 10,684 rows spanning
39
+ 2026-04-12 through 2026-04-24. None of these rows are migrated; the
40
+ "start fresh" decision (recorded in PR #261) means the extractor's
41
+ `--full-rebuild` mode rebuilds the table contents from local JSONL on
42
+ its next run against the new project.
43
+
44
+ **State after this migration:** Themes contains only its production
45
+ domain tables. The hook-log diagnostic store lives entirely in the new
46
+ Neon project `winter-haze-99075918` ("claude-hook-logs"), which the
47
+ updated `NEON_HOOK_LOGS_DATABASE_URL` secret now points at.
48
+
49
+ **Verification:** Before applying, confirm there is no other consumer
50
+ of `blocked_commands` or `hook_events` in the Themes project:
51
+
52
+ ```sql
53
+ SELECT table_name, view_definition
54
+ FROM information_schema.views
55
+ WHERE table_schema = 'public'
56
+ AND view_definition ILIKE '%hook_events%'
57
+ AND table_name <> 'blocked_commands';
58
+
59
+ SELECT conname, conrelid::regclass
60
+ FROM pg_constraint
61
+ WHERE confrelid = 'hook_events'::regclass;
62
+ ```
63
+
64
+ The first query excludes `blocked_commands` itself, since that view (also dropped by this migration) references `hook_events` in its definition and would otherwise always match.
65
+
66
+ Both queries should return zero rows; if either returns anything,
67
+ investigate the dependency before running the drop.
68
+
69
+ After applying, confirm the objects are gone:
70
+
71
+ ```sql
72
+ SELECT table_schema, table_name, table_type
73
+ FROM information_schema.tables
74
+ WHERE table_schema = 'public' AND table_name IN ('hook_events', 'blocked_commands');
75
+ ```
76
+
77
+ Should return zero rows.
@@ -0,0 +1,26 @@
1
+ -- Recent block details for the top-blocking hook in the last 30 days.
2
+ -- Shows the 25 most recent blocked attempts with the command excerpt and error
3
+ -- excerpt so diagnosis can focus on concrete failing commands rather than
4
+ -- aggregate counts. To target a specific hook, filter by hook_name in a follow-up.
5
+ WITH top_hook AS (
6
+ SELECT hook_name
7
+ FROM hook_events
8
+ WHERE outcome = 'blocked'
9
+ AND event_timestamp >= (NOW() - INTERVAL '30 days')
10
+ GROUP BY hook_name
11
+ ORDER BY COUNT(*) DESC
12
+ LIMIT 1
13
+ )
14
+ SELECT
15
+ event_timestamp,
16
+ session_id,
17
+ hook_event,
18
+ hook_name,
19
+ tool_name,
20
+ command_excerpt,
21
+ stderr_excerpt
22
+ FROM hook_events
23
+ WHERE outcome = 'blocked'
24
+ AND hook_name = (SELECT hook_name FROM top_hook)
25
+ ORDER BY event_timestamp DESC
26
+ LIMIT 25;
@@ -0,0 +1,10 @@
1
+ -- Total blocks grouped by hook category, all history.
2
+ SELECT
3
+ hook_category,
4
+ COUNT(*) AS block_count,
5
+ COUNT(DISTINCT hook_name) AS distinct_hook_count,
6
+ COUNT(DISTINCT session_id) AS distinct_session_count
7
+ FROM hook_events
8
+ WHERE outcome = 'blocked'
9
+ GROUP BY hook_category
10
+ ORDER BY block_count DESC;
@@ -0,0 +1,9 @@
1
+ -- Blocks grouped by tool name parsed from hook_name (e.g., Bash, Write, Edit).
2
+ SELECT
3
+ COALESCE(tool_name, '(none)') AS tool_name,
4
+ COUNT(*) AS block_count,
5
+ COUNT(DISTINCT hook_name) AS distinct_hook_count
6
+ FROM hook_events
7
+ WHERE outcome = 'blocked'
8
+ GROUP BY tool_name
9
+ ORDER BY block_count DESC;
@@ -0,0 +1,11 @@
1
+ -- Daily block counts for the last 7 days, grouped by hook.
2
+ SELECT
3
+ DATE_TRUNC('day', event_timestamp)::DATE AS block_day,
4
+ hook_name,
5
+ hook_category,
6
+ COUNT(*) AS block_count
7
+ FROM hook_events
8
+ WHERE outcome = 'blocked'
9
+ AND event_timestamp >= (NOW() - INTERVAL '7 days')
10
+ GROUP BY block_day, hook_name, hook_category
11
+ ORDER BY block_day DESC, block_count DESC;
@@ -0,0 +1,12 @@
1
+ -- Top 10 hooks by blocking count in the last 24 hours.
2
+ SELECT
3
+ hook_name,
4
+ hook_category,
5
+ COUNT(*) AS block_count_last_24_hours,
6
+ MIN(COALESCE(command_excerpt, stdout_excerpt, stderr_excerpt, '')) AS top_blocked_command_preview
7
+ FROM hook_events
8
+ WHERE outcome = 'blocked'
9
+ AND event_timestamp >= (NOW() - INTERVAL '1 day')
10
+ GROUP BY hook_name, hook_category
11
+ ORDER BY block_count_last_24_hours DESC
12
+ LIMIT 10;
@@ -0,0 +1,12 @@
1
+ -- Top 20 hooks by blocking count across all history.
2
+ SELECT
3
+ hook_name,
4
+ hook_category,
5
+ COUNT(*) AS block_count,
6
+ MIN(event_timestamp) AS first_block_at,
7
+ MAX(event_timestamp) AS last_block_at
8
+ FROM hook_events
9
+ WHERE outcome = 'blocked'
10
+ GROUP BY hook_name, hook_category
11
+ ORDER BY block_count DESC
12
+ LIMIT 20;
@@ -0,0 +1,2 @@
1
+ -r requirements-hook-logs.txt
2
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ psycopg[binary]>=3.1,<4
@@ -0,0 +1,51 @@
1
+ CREATE TABLE IF NOT EXISTS hook_events (
2
+ id BIGSERIAL PRIMARY KEY,
3
+ event_timestamp TIMESTAMPTZ NOT NULL,
4
+ session_id TEXT NOT NULL,
5
+ cwd TEXT,
6
+ git_branch TEXT,
7
+ hook_event TEXT NOT NULL,
8
+ hook_name TEXT NOT NULL,
9
+ hook_category TEXT NOT NULL,
10
+ script_path TEXT,
11
+ tool_name TEXT,
12
+ tool_use_id TEXT,
13
+ outcome TEXT NOT NULL,
14
+ exit_code INTEGER,
15
+ duration_ms INTEGER,
16
+ command_excerpt TEXT,
17
+ stdout_excerpt TEXT,
18
+ stderr_excerpt TEXT,
19
+ source_jsonl_path TEXT NOT NULL,
20
+ source_line_number INTEGER NOT NULL,
21
+ CONSTRAINT hook_events_source_location_unique UNIQUE (source_jsonl_path, source_line_number)
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_hook_events_category_outcome ON hook_events (hook_category, outcome);
25
+ CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (event_timestamp DESC);
26
+ CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name);
27
+ CREATE INDEX IF NOT EXISTS idx_hook_events_session ON hook_events (session_id);
28
+
29
+ CREATE OR REPLACE VIEW blocked_commands AS
30
+ SELECT
31
+ id,
32
+ event_timestamp,
33
+ session_id,
34
+ cwd,
35
+ git_branch,
36
+ hook_event,
37
+ hook_name,
38
+ hook_category,
39
+ script_path,
40
+ tool_name,
41
+ tool_use_id,
42
+ outcome,
43
+ exit_code,
44
+ duration_ms,
45
+ command_excerpt,
46
+ stdout_excerpt,
47
+ stderr_excerpt,
48
+ source_jsonl_path,
49
+ source_line_number
50
+ FROM hook_events
51
+ WHERE outcome = 'blocked';