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.
Files changed (32) hide show
  1. package/bin/install-constants.mjs +4 -0
  2. package/bin/install.cursor-rules.test.mjs +13 -2
  3. package/bin/install.mjs +5 -0
  4. package/bin/install.settings-defaults.test.mjs +0 -1
  5. package/hooks/advisory/owned_pr_review_loop.py +7 -1
  6. package/hooks/advisory/test_owned_pr_review_loop.py +23 -0
  7. package/hooks/blocking/owned_pr_merge_stop_gate.py +155 -0
  8. package/hooks/blocking/test_owned_pr_merge_stop_gate.py +163 -0
  9. package/hooks/features/bash-dispatchers.md +4 -1
  10. package/hooks/features/session-context.md +5 -4
  11. package/hooks/hooks.json +5 -13
  12. package/hooks/hooks_constants/compaction_user_messages_constants.py +141 -47
  13. package/hooks/hooks_constants/owned_pr_merge_stop_gate_constants.py +37 -0
  14. package/hooks/hooks_constants/owned_pr_review_loop_constants.py +14 -1
  15. package/hooks/owned_pr_session_record.py +104 -0
  16. package/hooks/session/compaction_recall_prompt.py +29 -18
  17. package/hooks/session/compaction_trim.py +252 -0
  18. package/hooks/session/compaction_trim_eval.py +85 -279
  19. package/hooks/session/compaction_user_messages.py +42 -143
  20. package/hooks/session/fixtures/compaction_user_messages/codex_rollout.jsonl +22 -0
  21. package/hooks/session/fixtures/compaction_user_messages/random_9031_1.jsonl +42 -0
  22. package/hooks/session/fixtures/compaction_user_messages/random_9031_2.jsonl +42 -0
  23. package/hooks/session/fixtures/compaction_user_messages/trim_cases.json +46 -0
  24. package/hooks/session/test_compaction_recall_prompt.py +45 -82
  25. package/hooks/session/test_compaction_trim.py +169 -0
  26. package/hooks/session/test_compaction_trim_eval.py +32 -85
  27. package/hooks/session/test_compaction_user_messages.py +37 -95
  28. package/hooks/test_owned_pr_session_record.py +46 -0
  29. package/package.json +1 -1
  30. package/scripts/policy_lint/adapter_configuration.py +6 -0
  31. package/scripts/policy_lint/config/constants.py +1 -0
  32. package/settings.json +1 -2
@@ -1,8 +1,8 @@
1
- """Names, limits, and texts for the compaction user-message recall hooks.
1
+ """Names, limits, and texts for the compaction user-message recall hook.
2
2
 
3
- Groups: the transcript entry shapes the extractor reads, the bounds and
4
- location of the file it writes, the SessionStart context that tells the agent
5
- to run the extractor, and the eval that runs the condensing hook live.
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
- MAXIMUM_MESSAGE_CHARACTERS = 4000
38
- MAXIMUM_EXTRACT_CHARACTERS = 60000
39
- CUT_MESSAGE_SUFFIX_TEMPLATE = "\n[{cut_count} more characters left out]"
40
- LEFT_OUT_MESSAGES_TEMPLATE = "[{left_out_count} earlier messages left out to fit the budget]\n\n"
41
- NO_MESSAGES_TEXT = "No typed user messages were found in this transcript.\n"
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
- ALL_RECALL_DIRECTORY_PARTS = (".claude", "compaction-recall")
46
- OUTPUT_FILE_SUFFIX = ".md"
47
- OUTPUT_ENCODING = "utf-8"
48
- TRANSCRIPT_ARGUMENT_COUNT = 2
49
- USAGE_TEXT = "usage: compaction_user_messages.py <transcript.jsonl>\n"
50
- USAGE_EXIT_CODE = 2
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
- "Run this Bash command first, before you reply and before any other tool call:\n\n"
62
- "{command}\n\n"
63
- "It prints a file path. A PostToolUse hook then reads that file and returns every "
64
- "message the user typed in this session, condensed, as a hook blocking error. That "
65
- "block is no error. It is the user's requests in order, and a later message overrides "
66
- "an earlier one. Hold them as the user's instructions next to the summary, then "
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
- EVAL_SESSION_DIRECTORY = Path(__file__).resolve().parent.parent / "session"
71
- EVAL_FIXTURE_DIRECTORY = EVAL_SESSION_DIRECTORY / "fixtures" / "compaction_user_messages"
72
- EVAL_HOOKS_JSON_PATH = EVAL_SESSION_DIRECTORY.parent / "hooks.json"
73
- EVAL_RESULTS_FILE_NAME = "results.json"
74
- EVAL_RESULTS_JSON_INDENT = 2
75
- EVAL_SERVED_MODEL_SEPARATOR = ","
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
- EVAL_CASES_FILE_NAME = "trim_cases.json"
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
- "Run each step yourself. The task ends at a merged PR or a named blocker."
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, tell the agent to recall the user's messages.
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 -> run the extractor command
7
- any other source, or no transcript_path -> nothing
6
+ SessionStart, source compact, transcript_path set -> the trimmed messages
7
+ any other source, no transcript_path, no messages -> nothing
8
8
 
9
- The context names one Bash command that runs ``compaction_user_messages.py``
10
- on this session's transcript. A PostToolUse agent hook in ``hooks.json``
11
- matches that command and returns the user's messages, condensed.
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 recall instruction for a compaction, or None for any other start.
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
- -> "...run this Bash command:\\n\\n'/usr/bin/python3' '.../compaction_user_messages.py' /t/abc.jsonl..."
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
- extractor_path = Path(__file__).resolve().parent / EXTRACTOR_SCRIPT_NAME
68
- command = shlex.join([sys.executable, str(extractor_path), transcript_path])
69
- return RECALL_CONTEXT_TEMPLATE.format(command=command)
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 recall instruction as SessionStart additionalContext, or stay quiet."""
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