claude-dev-env 8.90.1 → 8.92.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.
- package/bin/install-constants.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +13 -2
- package/bin/install.mjs +5 -0
- package/bin/install.settings-defaults.test.mjs +0 -1
- package/hooks/advisory/owned_pr_review_loop.py +7 -1
- package/hooks/advisory/test_owned_pr_review_loop.py +23 -0
- package/hooks/blocking/owned_pr_merge_stop_gate.py +155 -0
- package/hooks/blocking/test_owned_pr_merge_stop_gate.py +163 -0
- package/hooks/features/bash-dispatchers.md +4 -1
- package/hooks/features/session-context.md +5 -4
- package/hooks/hooks.json +5 -13
- package/hooks/hooks_constants/compaction_user_messages_constants.py +141 -47
- package/hooks/hooks_constants/owned_pr_merge_stop_gate_constants.py +37 -0
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +14 -1
- package/hooks/owned_pr_session_record.py +104 -0
- package/hooks/session/compaction_recall_prompt.py +29 -18
- package/hooks/session/compaction_trim.py +252 -0
- package/hooks/session/compaction_trim_eval.py +85 -279
- package/hooks/session/compaction_user_messages.py +42 -143
- package/hooks/session/fixtures/compaction_user_messages/codex_rollout.jsonl +22 -0
- package/hooks/session/fixtures/compaction_user_messages/random_9031_1.jsonl +42 -0
- package/hooks/session/fixtures/compaction_user_messages/random_9031_2.jsonl +42 -0
- package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +46 -0
- package/hooks/session/test_compaction_recall_prompt.py +45 -82
- package/hooks/session/test_compaction_trim.py +169 -0
- package/hooks/session/test_compaction_trim_eval.py +32 -85
- package/hooks/session/test_compaction_user_messages.py +37 -95
- package/hooks/test_owned_pr_session_record.py +46 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/settings.json +1 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Names, limits, and texts for the compaction user-message recall
|
|
1
|
+
"""Names, limits, and texts for the compaction user-message recall hook.
|
|
2
2
|
|
|
3
|
-
Groups: the transcript entry shapes the extractor reads,
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Groups: the Claude Code and Codex transcript entry shapes the extractor reads,
|
|
4
|
+
the trim rules that condense each message, the bounds of the recall text, the
|
|
5
|
+
SessionStart context that carries it, and the offline eval of the trim.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -34,20 +34,129 @@ HARNESS_TAG_OPENING_PATTERN = re.compile(r"<[a-z]+(?:-[a-z]+)+[\s>]")
|
|
|
34
34
|
SLASH_COMMAND_NAME_PATTERN = re.compile(r"<command-name>\s*(.*?)\s*</command-name>", re.DOTALL)
|
|
35
35
|
SLASH_COMMAND_ARGS_PATTERN = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL)
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
MESSAGE_HEADING_TEMPLATE = "## {position} | {timestamp}\n{text}\n\n"
|
|
43
|
-
MESSAGES_HEADING = "# User messages, oldest first\n\n"
|
|
37
|
+
CODEX_EVENT_ENTRY_TYPE = "event_msg"
|
|
38
|
+
CODEX_PAYLOAD_KEY = "payload"
|
|
39
|
+
CODEX_USER_MESSAGE_TYPE = "user_message"
|
|
40
|
+
CODEX_AGENT_MESSAGE_TYPE = "agent_message"
|
|
41
|
+
CODEX_IMAGES_KEY = "images"
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
GRATITUDE_PATTERN = re.compile(r"(?:thanks|thank\s+you|thx|ty)[.!]*", re.IGNORECASE)
|
|
44
|
+
INTERRUPT_PATTERN = re.compile(r"^\[request interrupted by user[^\]]*\]$", re.IGNORECASE)
|
|
45
|
+
PATH_CHARACTERS = r"\w/.:`-"
|
|
46
|
+
PATH_FOLLOWING_CHARACTERS = r"\w/:`-"
|
|
47
|
+
ALL_FILLER_PHRASES = (
|
|
48
|
+
"so basically",
|
|
49
|
+
"basically",
|
|
50
|
+
"just to be clear",
|
|
51
|
+
"honestly",
|
|
52
|
+
"I would like it if",
|
|
53
|
+
"could you please make sure that",
|
|
54
|
+
"could you please",
|
|
55
|
+
"can you please",
|
|
56
|
+
"could you",
|
|
57
|
+
"can you",
|
|
58
|
+
"please",
|
|
59
|
+
"when you get a chance",
|
|
60
|
+
"kind of",
|
|
61
|
+
"sort of",
|
|
62
|
+
"I mean",
|
|
63
|
+
)
|
|
64
|
+
FILLER_PATTERN = re.compile(
|
|
65
|
+
rf"(?<![{PATH_CHARACTERS}])(?:"
|
|
66
|
+
+ "|".join(re.escape(each_phrase) for each_phrase in ALL_FILLER_PHRASES)
|
|
67
|
+
+ rf")(?![{PATH_FOLLOWING_CHARACTERS}]|\.\w),?",
|
|
68
|
+
re.IGNORECASE,
|
|
69
|
+
)
|
|
70
|
+
ARTICLE_PATTERN = re.compile(
|
|
71
|
+
rf"(?<![{PATH_CHARACTERS}])(?:[Tt]he|THE|a|[Aa]n|AN)\s+(?=\w)"
|
|
72
|
+
)
|
|
73
|
+
ARTIFACT_COMMENT_MARKER = "[Artifact comment sent to Claude]"
|
|
74
|
+
ARTIFACT_URL_PATTERN = re.compile(r"^Artifact: (\S+)", re.MULTILINE)
|
|
75
|
+
ARTIFACT_LOCATION_PATTERN = re.compile(r"^Location: (.+)$", re.MULTILINE)
|
|
76
|
+
ARTIFACT_QUOTE_PATTERN = re.compile(
|
|
77
|
+
r"=== BEGIN ARTIFACT COMMENT (?P<marker>\w+) ===\n(?P<comment>.*?)\n"
|
|
78
|
+
r"=== END ARTIFACT COMMENT (?P=marker) ===",
|
|
79
|
+
re.DOTALL,
|
|
80
|
+
)
|
|
81
|
+
QUOTE_PREFIX_PATTERN = re.compile(r"^> ?", re.MULTILINE)
|
|
82
|
+
ARTIFACT_COMMENT_TEMPLATE = "Artifact comment on {url}{location}: {comment}"
|
|
83
|
+
ARTIFACT_LOCATION_TEMPLATE = " at {location}"
|
|
84
|
+
ARTIFACT_QUOTE_GROUP = "comment"
|
|
85
|
+
HTML_COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
86
|
+
CODE_SPAN_PATTERN = re.compile(r"(`[^`]*`)")
|
|
87
|
+
WHITESPACE_RUN_PATTERN = re.compile(r"\s+")
|
|
88
|
+
SPACE_BEFORE_PUNCTUATION_PATTERN = re.compile(r"\s+([,.;:])(?=\s|$)")
|
|
89
|
+
DANGLING_COMMA_PATTERN = re.compile(r",\s*(?=[,.;:](?:\s|$))")
|
|
90
|
+
TRIMMED_EDGE_CHARACTERS = " ,"
|
|
91
|
+
REVERSAL_PATTERN = re.compile(
|
|
92
|
+
r"change of plan|change that|on second thought|scratch (?:that|the)|"
|
|
93
|
+
r"(?<!n't )(?<!nt )forget\b|after all|"
|
|
94
|
+
r"instead|\bswitch(?:ed)? to\b|vetoed|no longer|not anymore|\boverride\b|\brevert\b|\bundo\b|"
|
|
95
|
+
r"don'?t (?:use|do)|stop using",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
CONTENT_WORD_PATTERN = re.compile(r"[A-Za-z][\w./:-]+")
|
|
99
|
+
CONTENT_WORD_EDGE_PUNCTUATION = ".,:;"
|
|
100
|
+
ALL_STOP_WORDS = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"to",
|
|
103
|
+
"it",
|
|
104
|
+
"is",
|
|
105
|
+
"we",
|
|
106
|
+
"of",
|
|
107
|
+
"on",
|
|
108
|
+
"in",
|
|
109
|
+
"be",
|
|
110
|
+
"so",
|
|
111
|
+
"do",
|
|
112
|
+
"no",
|
|
113
|
+
"as",
|
|
114
|
+
"at",
|
|
115
|
+
"by",
|
|
116
|
+
"or",
|
|
117
|
+
"us",
|
|
118
|
+
"me",
|
|
119
|
+
"if",
|
|
120
|
+
"an",
|
|
121
|
+
"the",
|
|
122
|
+
"and",
|
|
123
|
+
"for",
|
|
124
|
+
"use",
|
|
125
|
+
"not",
|
|
126
|
+
"with",
|
|
127
|
+
"this",
|
|
128
|
+
"that",
|
|
129
|
+
"its",
|
|
130
|
+
"from",
|
|
131
|
+
"should",
|
|
132
|
+
"instead",
|
|
133
|
+
"drop",
|
|
134
|
+
"after",
|
|
135
|
+
"all",
|
|
136
|
+
"change",
|
|
137
|
+
"plan",
|
|
138
|
+
"switch",
|
|
139
|
+
"forget",
|
|
140
|
+
"let's",
|
|
141
|
+
"second",
|
|
142
|
+
"thought",
|
|
143
|
+
"scratch",
|
|
144
|
+
"idea",
|
|
145
|
+
"call",
|
|
146
|
+
"update",
|
|
147
|
+
"ops",
|
|
148
|
+
"vetoed",
|
|
149
|
+
"go",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
TRIMMED_LINE_TEMPLATE = "#{position} {text}"
|
|
153
|
+
OVERRULED_SUFFIX_TEMPLATE = " (overruled by #{later_position})"
|
|
154
|
+
|
|
155
|
+
MAXIMUM_MESSAGE_CHARACTERS = 1500
|
|
156
|
+
MAXIMUM_RECALL_CHARACTERS = 6000
|
|
157
|
+
CUT_MESSAGE_SUFFIX_TEMPLATE = " [{cut_count} more characters left out]"
|
|
158
|
+
LEFT_OUT_LINES_TEMPLATE = "[{left_out_count} earlier messages left out to fit the budget]"
|
|
159
|
+
LINE_SEPARATOR = "\n"
|
|
51
160
|
|
|
52
161
|
SESSION_START_EVENT_NAME = "SessionStart"
|
|
53
162
|
COMPACTION_SOURCE = "compact"
|
|
@@ -55,39 +164,24 @@ HOOK_EVENT_NAME_INPUT_KEY = "hook_event_name"
|
|
|
55
164
|
SOURCE_INPUT_KEY = "source"
|
|
56
165
|
TRANSCRIPT_PATH_INPUT_KEY = "transcript_path"
|
|
57
166
|
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
58
|
-
EXTRACTOR_SCRIPT_NAME = "compaction_user_messages.py"
|
|
59
167
|
RECALL_CONTEXT_TEMPLATE = (
|
|
60
168
|
"The context was just compacted, so the user's own words are gone from it. "
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"continue the work. Read the printed file only when no condensed block arrives."
|
|
169
|
+
"These are the messages the user typed in this session, oldest first, trimmed "
|
|
170
|
+
"to their substance. A later message overrides an earlier one. A line that "
|
|
171
|
+
"ends with (overruled by #N) is one that message N changes, so read the two "
|
|
172
|
+
"together and follow message N where they differ. When message N was left "
|
|
173
|
+
"out to fit the budget, treat the marked line as replaced. Hold them as the user's "
|
|
174
|
+
"instructions next to the summary, then continue the work.\n\n{lines}"
|
|
68
175
|
)
|
|
69
176
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
177
|
+
EVAL_FIXTURE_DIRECTORY = Path(__file__).resolve().parent.parent / "session" / "fixtures" / (
|
|
178
|
+
"compaction_user_messages"
|
|
179
|
+
)
|
|
180
|
+
EVAL_CASES_FILE_NAME = "trim_cases.json"
|
|
181
|
+
EVAL_CASE_TRANSCRIPT_KEY = "transcript"
|
|
182
|
+
EVAL_CASE_FACTS_KEY = "facts"
|
|
183
|
+
EVAL_CASE_OVERRULED_KEY = "overruled"
|
|
184
|
+
EVAL_CASE_EARLIER_KEY = "earlier"
|
|
76
185
|
EVAL_MESSAGE_LINE_PATTERN_TEMPLATE = r"^\W*#?{position}\b.*$"
|
|
77
186
|
EVAL_OVERRULED_MARKER = "overruled"
|
|
78
|
-
|
|
79
|
-
EVAL_CLAUDE_EXECUTABLE = "claude"
|
|
80
|
-
EVAL_DEFAULT_MODEL = "haiku"
|
|
81
|
-
EVAL_HOOK_TYPE = "agent"
|
|
82
|
-
EVAL_TIMEOUT_SECONDS = 240
|
|
83
|
-
EVAL_JSON_OBJECT_START = "{"
|
|
84
|
-
EVAL_PACKAGE_SETTINGS_PATH = EVAL_SESSION_DIRECTORY.parent.parent / "settings.json"
|
|
85
|
-
EVAL_RUN_SETTINGS_FILE_NAME = "hook-settings.json"
|
|
86
|
-
EVAL_DEBUG_FILE_NAME = "debug.log"
|
|
87
|
-
EVAL_STRUCTURED_OUTPUT_MARKER = "Hooks: Got structured output: "
|
|
88
|
-
EVAL_DECISION_SEPARATOR = "\n"
|
|
89
|
-
EVAL_ALLOWED_TOOLS = "Bash(python3:*)"
|
|
90
|
-
EVAL_EMPTY_MCP_CONFIG = '{"mcpServers": {}}'
|
|
91
|
-
EVAL_RUN_PROMPT_TEMPLATE = (
|
|
92
|
-
"Run this exact Bash command once, then reply with one word, done:\n\n{command}"
|
|
93
|
-
)
|
|
187
|
+
EVAL_FILE_ENCODING = "utf-8"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Constants for the owned pull request merge Stop gate."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
HOOK_EVENT_NAME = "Stop"
|
|
6
|
+
SESSION_ID_KEY = "session_id"
|
|
7
|
+
LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
|
|
8
|
+
DECISION_KEY = "decision"
|
|
9
|
+
BLOCK_DECISION = "block"
|
|
10
|
+
REASON_KEY = "reason"
|
|
11
|
+
ALLOW_EXIT_CODE = 0
|
|
12
|
+
GH_PROGRAM_NAME = "gh"
|
|
13
|
+
GH_API_SUBCOMMAND = "api"
|
|
14
|
+
GH_JQ_OPTION = "--jq"
|
|
15
|
+
PULL_REQUEST_ENDPOINT_TEMPLATE = "repos/{owner}/{repository}/pulls/{number}"
|
|
16
|
+
PULL_REQUEST_STATE_QUERY = ".state"
|
|
17
|
+
OPEN_PULL_REQUEST_STATE = "open"
|
|
18
|
+
STATE_QUERY_TIMEOUT_SECONDS = 10
|
|
19
|
+
LOOKUP_FAILURE_TEMPLATE = "owned_pr_merge_stop_gate: could not read {endpoint}: {detail}\n"
|
|
20
|
+
BLOCKED_LINE_PATTERN: re.Pattern[str] = re.compile(
|
|
21
|
+
r"^\s*(?:[-*]\s*)?blocked\s*:(.*)$", re.IGNORECASE
|
|
22
|
+
)
|
|
23
|
+
BLOCKED_TEXT_GROUP_INDEX = 1
|
|
24
|
+
BOLD_MARKER = "**"
|
|
25
|
+
NAME_START_PATTERN = r"(?<![0-9A-Za-z_./-])"
|
|
26
|
+
NAME_END_PATTERN = r"(?![0-9A-Za-z/])"
|
|
27
|
+
PULL_REQUEST_REFERENCE_TEMPLATE = "{owner}/{repository}#{number}"
|
|
28
|
+
PULL_REQUEST_LINE_TEMPLATE = "\n- {url}"
|
|
29
|
+
BLOCK_REASON_PREFIX = "You opened these owned pull requests and they are still open:"
|
|
30
|
+
BLOCK_REASON_SUFFIX = (
|
|
31
|
+
"\nThe repository owner approves the whole owned PR loop, the merge included."
|
|
32
|
+
" Keep working now: run /efficient-review until a pass is clean, mark the PR ready,"
|
|
33
|
+
" babysit CI and review threads, then merge it and read it back as merged."
|
|
34
|
+
" End the turn early only with one line per pull request:"
|
|
35
|
+
" 'Blocked: <owner>/<repository>#<number> - <what only the owner can give>'."
|
|
36
|
+
" A user who asked to hold the PR or to read it first is such a blocker."
|
|
37
|
+
)
|
|
@@ -48,6 +48,9 @@ __all__ = [
|
|
|
48
48
|
"ALL_DECLARED_URL_KEYS",
|
|
49
49
|
"JSON_OBJECT_OPENER",
|
|
50
50
|
"REVIEW_LOOP_CONTEXT_TEMPLATE",
|
|
51
|
+
"SESSION_RECORD_FILE_TEMPLATE",
|
|
52
|
+
"SESSION_RECORD_TEMPORARY_PREFIX",
|
|
53
|
+
"ALL_PULL_REQUEST_FIELD_NAMES",
|
|
51
54
|
]
|
|
52
55
|
|
|
53
56
|
NAME_DIGEST_TEXT_ENCODING: str = "utf-8"
|
|
@@ -99,10 +102,16 @@ ALREADY_EXISTS_MARKER: str = "already exists"
|
|
|
99
102
|
ALL_DECLARED_URL_KEYS: tuple[str, ...] = ("html_url", "url")
|
|
100
103
|
JSON_OBJECT_OPENER: str = "{"
|
|
101
104
|
|
|
105
|
+
SESSION_RECORD_FILE_TEMPLATE: str = "owned-pr-loop-{session_file_id}.json"
|
|
106
|
+
SESSION_RECORD_TEMPORARY_PREFIX: str = "owned-pr-loop-"
|
|
107
|
+
ALL_PULL_REQUEST_FIELD_NAMES: tuple[str, ...] = ("owner", "repository", "number", "url")
|
|
108
|
+
|
|
102
109
|
REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
|
|
103
110
|
"=== OWNED PR REVIEW LOOP (context reminder, never a block) ===\n"
|
|
104
111
|
"You opened {owner}/{repository}#{number} {url}\n"
|
|
105
112
|
"This repository has no automated review, so you own the review and the merge.\n"
|
|
113
|
+
"Your task now runs past opening this PR to its merge, also when your brief\n"
|
|
114
|
+
"asked for a draft or stopped at opening it.\n"
|
|
106
115
|
"1. Review loop. Load the efficient-review skill (/efficient-review). Review the\n"
|
|
107
116
|
" branch, check each finding against the code, fix every one that holds, push,\n"
|
|
108
117
|
" and review again. Repeat until a pass reports no findings.\n"
|
|
@@ -116,5 +125,9 @@ REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
|
|
|
116
125
|
"4. Ship. Follow the pstack shipping playbook (poteto-mode/playbooks/shipping.md)\n"
|
|
117
126
|
" to land the PR, or the pr-lifecycle skill outside poteto-mode. Read the PR\n"
|
|
118
127
|
" back and confirm it reads merged.\n"
|
|
119
|
-
"
|
|
128
|
+
"The repository owner installed this hook and\n"
|
|
129
|
+
"approves every step above, the merge included. Run each step yourself\n"
|
|
130
|
+
"without asking first. The task ends at a merged PR or a named blocker.\n"
|
|
131
|
+
"When the user asked to hold this PR or to read it before it merges, stop\n"
|
|
132
|
+
"after step 3 and end with: Blocked: {owner}/{repository}#{number} - the user holds the merge."
|
|
120
133
|
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Session record of the owned pull requests an agent opened.
|
|
2
|
+
|
|
3
|
+
The owned pull request review-loop hook writes each pull request it reports
|
|
4
|
+
into one JSON file per session in the system temp folder. The owned pull
|
|
5
|
+
request Stop gate reads the same file back::
|
|
6
|
+
|
|
7
|
+
owned-pr-loop-<session id>.json
|
|
8
|
+
[{"owner": "acme", "repository": "widgets", "number": "42",
|
|
9
|
+
"url": "https://github.com/acme/widgets/pull/42"}]
|
|
10
|
+
|
|
11
|
+
Both Claude Code and Codex send ``session_id`` to PostToolUse and Stop hooks,
|
|
12
|
+
so the file links the two hooks on either host.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from atomic_file_writer import write_text_atomically
|
|
22
|
+
from hooks_constants.atomic_file_writer_constants import ATOMIC_WRITE_TEMPORARY_SUFFIX
|
|
23
|
+
from hooks_constants.owned_pr_review_loop_constants import (
|
|
24
|
+
ALL_PULL_REQUEST_FIELD_NAMES,
|
|
25
|
+
NAME_DIGEST_TEXT_ENCODING,
|
|
26
|
+
SESSION_RECORD_FILE_TEMPLATE,
|
|
27
|
+
SESSION_RECORD_TEMPORARY_PREFIX,
|
|
28
|
+
)
|
|
29
|
+
from hooks_constants.session_file_id import session_file_id
|
|
30
|
+
|
|
31
|
+
__all__ = ["session_record_path", "all_recorded_pull_requests", "record_pull_request"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def session_record_path(session_id: object) -> Path:
|
|
35
|
+
"""Return the record file path for one session.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
39
|
+
"""
|
|
40
|
+
file_name = SESSION_RECORD_FILE_TEMPLATE.format(session_file_id=session_file_id(session_id))
|
|
41
|
+
return Path(tempfile.gettempdir()) / file_name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_session_id(session_id: object) -> bool:
|
|
45
|
+
return isinstance(session_id, str) and bool(session_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_pull_request_entry(entry: object) -> bool:
|
|
49
|
+
return isinstance(entry, dict) and all(
|
|
50
|
+
isinstance(entry.get(each_field), str) for each_field in ALL_PULL_REQUEST_FIELD_NAMES
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def all_recorded_pull_requests(session_id: object) -> list[dict[str, str]]:
|
|
55
|
+
"""Return the pull requests recorded for the session, oldest first.
|
|
56
|
+
|
|
57
|
+
A payload with no session id, or a missing, unreadable, or malformed
|
|
58
|
+
file, reads as an empty record.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
62
|
+
"""
|
|
63
|
+
if not _is_session_id(session_id):
|
|
64
|
+
return []
|
|
65
|
+
try:
|
|
66
|
+
record_text = session_record_path(session_id).read_text(encoding=NAME_DIGEST_TEXT_ENCODING)
|
|
67
|
+
all_entries = json.loads(record_text)
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return []
|
|
70
|
+
if not isinstance(all_entries, list):
|
|
71
|
+
return []
|
|
72
|
+
return [each_entry for each_entry in all_entries if _is_pull_request_entry(each_entry)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def record_pull_request(session_id: object, all_pull_request_fields: dict[str, str]) -> None:
|
|
76
|
+
"""Add one pull request to the session record, once per url.
|
|
77
|
+
|
|
78
|
+
A payload with no session id records nothing. A write that fails leaves
|
|
79
|
+
the record as it was, so the PostToolUse hook still adds its context.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
83
|
+
all_pull_request_fields: The owner, repository, number, and url fields.
|
|
84
|
+
"""
|
|
85
|
+
if not _is_session_id(session_id):
|
|
86
|
+
return
|
|
87
|
+
all_entries = all_recorded_pull_requests(session_id)
|
|
88
|
+
if any(each_entry["url"] == all_pull_request_fields["url"] for each_entry in all_entries):
|
|
89
|
+
return
|
|
90
|
+
new_entry = {
|
|
91
|
+
each_field: all_pull_request_fields[each_field]
|
|
92
|
+
for each_field in ALL_PULL_REQUEST_FIELD_NAMES
|
|
93
|
+
}
|
|
94
|
+
try:
|
|
95
|
+
write_text_atomically(
|
|
96
|
+
session_record_path(session_id),
|
|
97
|
+
json.dumps([*all_entries, new_entry]),
|
|
98
|
+
NAME_DIGEST_TEXT_ENCODING,
|
|
99
|
+
SESSION_RECORD_TEMPORARY_PREFIX,
|
|
100
|
+
ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
101
|
+
False,
|
|
102
|
+
)
|
|
103
|
+
except (OSError, UnicodeError):
|
|
104
|
+
return
|
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook: after a compaction,
|
|
2
|
+
"""SessionStart hook: after a compaction, return the user's typed messages, trimmed.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
6
|
-
SessionStart, source compact, transcript_path set ->
|
|
7
|
-
any other source,
|
|
6
|
+
SessionStart, source compact, transcript_path set -> the trimmed messages
|
|
7
|
+
any other source, no transcript_path, no messages -> nothing
|
|
8
8
|
|
|
9
|
-
The
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
The hook reads the session transcript, keeps the messages a person typed,
|
|
10
|
+
trims each one to its substance in Python, and returns the lines as
|
|
11
|
+
additionalContext. It runs as a command hook, so Claude Code and Codex both
|
|
12
|
+
run it, and it calls no model.
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
15
|
from __future__ import annotations
|
|
15
16
|
|
|
16
17
|
import json
|
|
17
|
-
import shlex
|
|
18
18
|
import sys
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
22
22
|
if _hooks_directory not in sys.path:
|
|
23
23
|
sys.path.insert(0, _hooks_directory)
|
|
24
|
+
_session_directory = str(Path(__file__).resolve().parent)
|
|
25
|
+
if _session_directory not in sys.path:
|
|
26
|
+
sys.path.insert(0, _session_directory)
|
|
24
27
|
|
|
28
|
+
from compaction_trim import recall_text
|
|
29
|
+
from compaction_user_messages import typed_user_messages
|
|
25
30
|
from hooks_constants.compaction_user_messages_constants import (
|
|
26
31
|
ADDITIONAL_CONTEXT_KEY,
|
|
27
32
|
COMPACTION_SOURCE,
|
|
28
|
-
EXTRACTOR_SCRIPT_NAME,
|
|
29
33
|
HOOK_EVENT_NAME_INPUT_KEY,
|
|
30
34
|
RECALL_CONTEXT_TEMPLATE,
|
|
31
35
|
SESSION_START_EVENT_NAME,
|
|
@@ -37,25 +41,24 @@ from hooks_constants.hook_specific_output_keys import (
|
|
|
37
41
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
38
42
|
)
|
|
39
43
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
44
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
def recall_context(all_hook_fields: dict[str, object]) -> str | None:
|
|
43
|
-
"""Return the
|
|
48
|
+
"""Return the trimmed messages for a compaction, or None for any other start.
|
|
44
49
|
|
|
45
50
|
::
|
|
46
51
|
|
|
47
52
|
{"hook_event_name": "SessionStart", "source": "compact",
|
|
48
53
|
"transcript_path": "/t/abc.jsonl"}
|
|
49
|
-
-> "
|
|
50
|
-
|
|
51
|
-
The command names the interpreter running this hook, so it works where
|
|
52
|
-
``python3`` is not on PATH.
|
|
54
|
+
-> "The context was just compacted, ...\\n\\n#1 fix login bug\\n#3 ..."
|
|
53
55
|
|
|
54
56
|
Args:
|
|
55
57
|
all_hook_fields: The parsed hook input.
|
|
56
58
|
|
|
57
59
|
Returns:
|
|
58
|
-
The additionalContext text, or None
|
|
60
|
+
The additionalContext text, or None when the start is no compaction,
|
|
61
|
+
the transcript cannot be read, or it holds no typed message.
|
|
59
62
|
"""
|
|
60
63
|
if all_hook_fields.get(HOOK_EVENT_NAME_INPUT_KEY) != SESSION_START_EVENT_NAME:
|
|
61
64
|
return None
|
|
@@ -64,13 +67,21 @@ def recall_context(all_hook_fields: dict[str, object]) -> str | None:
|
|
|
64
67
|
transcript_path = all_hook_fields.get(TRANSCRIPT_PATH_INPUT_KEY)
|
|
65
68
|
if not isinstance(transcript_path, str) or not transcript_path:
|
|
66
69
|
return None
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
try:
|
|
71
|
+
with Path(transcript_path).open(
|
|
72
|
+
encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
|
|
73
|
+
) as transcript:
|
|
74
|
+
all_messages = typed_user_messages(transcript)
|
|
75
|
+
except OSError:
|
|
76
|
+
return None
|
|
77
|
+
trimmed_messages = recall_text(all_messages)
|
|
78
|
+
if trimmed_messages is None:
|
|
79
|
+
return None
|
|
80
|
+
return RECALL_CONTEXT_TEMPLATE.format(lines=trimmed_messages)
|
|
70
81
|
|
|
71
82
|
|
|
72
83
|
def main() -> None:
|
|
73
|
-
"""Print the
|
|
84
|
+
"""Print the trimmed messages as SessionStart additionalContext, or stay quiet."""
|
|
74
85
|
hook_input = read_hook_input_dictionary_from_stdin()
|
|
75
86
|
if hook_input is None:
|
|
76
87
|
return
|