claude-dev-env 8.74.2 → 8.75.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.
@@ -1,30 +1,30 @@
1
1
  ---
2
2
  name: correction
3
- description: File a correction the user made to an agent as one labeled GitHub issue, list the open corrections, write a handoff brief, or land the fix in this session with the fix playbook. Use when the user types /correction, /correction fix, says "file this as a correction", or asks to show the open corrections.
3
+ description: Turn a correction the user made to an agent into a Suggested task card that starts a fix session in one click, list the corrections filed as issues before the card, write a handoff brief, or land the fix in this session with the fix playbook. Use when the user types /correction, /correction fix, says "file this as a correction", or asks to show the open corrections.
4
4
  argument-hint: "[fix] <what the agent got wrong, in the user's words>"
5
5
  ---
6
6
 
7
7
  # /correction
8
8
 
9
- The user corrected an agent and wants the fix to stick. A labeled issue is the shared store a corrections project watches. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself: write the brief, then follow the fix playbook.
9
+ The user corrected an agent and wants the fix to stick. By default the correction becomes a Suggested task card, and the user starts the fix session from it in one click. No issue is filed. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself: write the brief, then follow the fix playbook.
10
10
 
11
- ## File it
11
+ ## Hand it off
12
12
 
13
- Use this for `/correction <text>` and "file this as a correction: <text>". Run:
13
+ Use this for `/correction <text>` and "file this as a correction: <text>".
14
14
 
15
- ```bash
16
- python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" file <<'CORRECTION'
17
- <the correction, word for word>
18
- CORRECTION
19
- ```
20
-
21
- Pass the user's words exactly. The quoted heredoc keeps the shell from expanding `$`, backticks, and quotes in them. Print the one line the command prints, `Filed: <url>` or `Already filed: <url>`, and nothing else.
15
+ 1. Write the brief below, then build the card from it with [`scripts/correction_task_card.py`](scripts/correction_task_card.py). Pipe one JSON object in a quoted heredoc: `title` starts with a verb and stays under 60 characters, `tldr` is one plain sentence, `brief` is the brief block text, and `files` lists the repository-relative paths the brief names.
16
+ ```bash
17
+ python "${CLAUDE_SKILL_DIR}/scripts/correction_task_card.py" <<'CARD'
18
+ {"title": "<verb first>", "tldr": "<one sentence>", "brief": "<brief block, newlines as \n>", "files": ["<repo-relative path>"]}
19
+ CARD
20
+ ```
22
21
 
23
- When it prints `No correction filing config`, write the brief below instead and tell the user the config file it names.
22
+ 2. When the session has `mcp__ccd_session__spawn_task`, call it with the `title`, `tldr`, and `prompt` the script prints. Reply with the card title.
23
+ 3. Without that tool, run the script again with `--prompt-only` and print its output in one fenced `text` block as the handoff brief.
24
24
 
25
25
  ## List them
26
26
 
27
- For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines.
27
+ For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines. It lists the issues filed before the card replaced filing, and the issues the correction flag mod still files.
28
28
 
29
29
  ## Brief
30
30
 
@@ -46,16 +46,19 @@ Keep each field to one or two lines. Leave out the layer and the fix design; the
46
46
 
47
47
  | Mode | What follows the brief |
48
48
  |---|---|
49
- | File, the default | The filed line, or the brief when no config exists. |
49
+ | Hand off, the default | The task card, or the handoff brief when `spawn_task` is absent. |
50
50
  | `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
51
51
 
52
52
  ## Layout
53
53
 
54
54
  | File | What it holds |
55
55
  |---|---|
56
- | `SKILL.md` | Filing, the brief, and the playbook index |
56
+ | `SKILL.md` | The handoff, the listing, the brief, and the playbook index |
57
57
  | `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
58
- | `scripts/correction_filing.py` | Files one correction as a labeled issue, deduplicated, or lists the open ones |
58
+ | `scripts/correction_task_card.py` | Builds the `spawn_task` title, tldr, and standalone prompt from the brief and the fix playbook |
59
+ | `scripts/correction_task_card_constants/config/constants.py` | Card field rules and the prompt template |
60
+ | `scripts/test_correction_task_card.py` | Behavior tests for the card against the shipped fix playbook |
61
+ | `scripts/correction_filing.py` | Lists the open correction issues, and files one for the correction flag mod |
59
62
  | `scripts/correction_filing_constants/config/constants.py` | Config keys, issue shape, and redaction patterns |
60
63
  | `scripts/test_correction_filing.py` | Behavior tests with a stand-in `gh` |
61
64
  | `references/filing.md` | Config file, issue shape, dedupe, and commands |
@@ -1,6 +1,6 @@
1
1
  # Correction filing
2
2
 
3
- `scripts/correction_filing.py` is the one filing path. The skill calls it.
3
+ `scripts/correction_filing.py` files a correction as a labeled issue for the correction flag mod in claude-mods-framework, which runs `correction_filing.py file --source flag`. `/correction` hands a correction off as a task card and files no issue. The skill runs only `list`, which reads the issues filed before the card and the issues the flag mod files.
4
4
 
5
5
  ## Config
6
6
 
@@ -0,0 +1,194 @@
1
+ """Build the Suggested task card that hands one correction to a fix session.
2
+
3
+ The agent pipes a JSON object on standard input, and the script prints the
4
+ three fields `spawn_task` takes::
5
+
6
+ python correction_task_card.py <<'CARD'
7
+ {"title": "Add a lint for bare except in hooks",
8
+ "tldr": "Agents keep writing bare except blocks in hooks.",
9
+ "brief": "Correction: stop writing bare except ...",
10
+ "files": ["packages/claude-dev-env/hooks/blocking/example.py"]}
11
+ CARD
12
+ {"title": "Add a lint ...", "tldr": "Agents keep ...", "prompt": "Land this correction ..."}
13
+
14
+ The prompt carries the brief, the files, and the fix playbook steps from step 2
15
+ on, so a fresh session needs nothing else. `--prompt-only` prints the prompt
16
+ alone, for the handoff brief a session without `spawn_task` shows instead.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from correction_task_card_constants.config.constants import (
28
+ ABSOLUTE_PATH_PATTERN,
29
+ ALL_CARD_KEYS,
30
+ BRIEF_KEY,
31
+ FAILURE_EXIT_CODE,
32
+ FILE_LINE_TEMPLATE,
33
+ FILES_KEY,
34
+ FIX_PLAYBOOK_DIRECTORY_NAME,
35
+ FIX_PLAYBOOK_FILE_NAME,
36
+ FIX_PLAYBOOK_FIRST_HANDED_OFF_STEP,
37
+ INVALID_INPUT_MESSAGE_TEMPLATE,
38
+ LINE_SEPARATOR,
39
+ MARKDOWN_LINK_PATTERN,
40
+ MAXIMUM_TITLE_LENGTH,
41
+ NO_FILES_LINE,
42
+ PROMPT_TEMPLATE,
43
+ REASON_ABSOLUTE_FILE_TEMPLATE,
44
+ REASON_EMPTY_FIELD_TEMPLATE,
45
+ REASON_FILES_NOT_A_LIST,
46
+ REASON_MULTI_LINE_FIELD_TEMPLATE,
47
+ REASON_NOT_AN_OBJECT,
48
+ REASON_TITLE_TOO_LONG_TEMPLATE,
49
+ SUCCESS_EXIT_CODE,
50
+ TITLE_KEY,
51
+ TLDR_KEY,
52
+ UTF8_ENCODING,
53
+ )
54
+
55
+
56
+ class CardInputRunFatal(ValueError):
57
+ """Stop the run when the piped card input breaks a field rule."""
58
+
59
+
60
+ def _fix_playbook_path() -> Path:
61
+ skill_directory = Path(__file__).resolve().parent.parent
62
+ return skill_directory / FIX_PLAYBOOK_DIRECTORY_NAME / FIX_PLAYBOOK_FILE_NAME
63
+
64
+
65
+ def handed_off_playbook_steps(playbook_text: str) -> str:
66
+ """Return the fix playbook from step 2 on, with each link reduced to its text.
67
+
68
+ Args:
69
+ playbook_text: the whole fix playbook.
70
+
71
+ Returns:
72
+ Step 2 through the reply line, readable without the skill's folder.
73
+ """
74
+ first_step_index = (
75
+ playbook_text.index(LINE_SEPARATOR + FIX_PLAYBOOK_FIRST_HANDED_OFF_STEP) + 1
76
+ )
77
+ return re.sub(
78
+ MARKDOWN_LINK_PATTERN, r"\1", playbook_text[first_step_index:]
79
+ ).strip()
80
+
81
+
82
+ def _required_text(all_card_fields: dict[str, object], field: str) -> str:
83
+ raw_text = all_card_fields.get(field, "")
84
+ text = raw_text.strip() if isinstance(raw_text, str) else ""
85
+ if not text:
86
+ raise CardInputRunFatal(REASON_EMPTY_FIELD_TEMPLATE.format(field=field))
87
+ return text
88
+
89
+
90
+ def _one_line_text(all_card_fields: dict[str, object], field: str) -> str:
91
+ text = _required_text(all_card_fields, field)
92
+ if LINE_SEPARATOR in text:
93
+ raise CardInputRunFatal(REASON_MULTI_LINE_FIELD_TEMPLATE.format(field=field))
94
+ return text
95
+
96
+
97
+ def _card_title(all_card_fields: dict[str, object]) -> str:
98
+ title = _one_line_text(all_card_fields, TITLE_KEY)
99
+ if len(title) > MAXIMUM_TITLE_LENGTH:
100
+ raise CardInputRunFatal(
101
+ REASON_TITLE_TOO_LONG_TEMPLATE.format(
102
+ length=len(title), limit=MAXIMUM_TITLE_LENGTH
103
+ )
104
+ )
105
+ return title
106
+
107
+
108
+ def _file_lines(all_card_fields: dict[str, object]) -> str:
109
+ all_files = all_card_fields.get(FILES_KEY, [])
110
+ if not isinstance(all_files, list) or not all(
111
+ isinstance(each, str) for each in all_files
112
+ ):
113
+ raise CardInputRunFatal(REASON_FILES_NOT_A_LIST)
114
+ all_named_paths = [each_path.strip() for each_path in all_files if each_path.strip()]
115
+ for each_path in all_named_paths:
116
+ if re.match(ABSOLUTE_PATH_PATTERN, each_path):
117
+ raise CardInputRunFatal(REASON_ABSOLUTE_FILE_TEMPLATE.format(path=each_path))
118
+ all_lines = [FILE_LINE_TEMPLATE.format(path=each_path) for each_path in all_named_paths]
119
+ return LINE_SEPARATOR.join(all_lines) or NO_FILES_LINE
120
+
121
+
122
+ def build_task_card(
123
+ all_card_fields: dict[str, object], playbook_text: str
124
+ ) -> dict[str, str]:
125
+ """Return the title, tldr, and standalone prompt for `spawn_task`.
126
+
127
+ Args:
128
+ all_card_fields: the parsed object with title, tldr, brief, and files.
129
+ playbook_text: the whole fix playbook.
130
+
131
+ Returns:
132
+ The three `spawn_task` fields, in the order the tool lists them.
133
+
134
+ Raises:
135
+ CardInputRunFatal: a field is empty, spans lines, runs long, or names
136
+ an absolute file path.
137
+ """
138
+ prompt = PROMPT_TEMPLATE.format(
139
+ brief=_required_text(all_card_fields, BRIEF_KEY),
140
+ file_lines=_file_lines(all_card_fields),
141
+ playbook_steps=handed_off_playbook_steps(playbook_text),
142
+ )
143
+ all_card_texts = (
144
+ _card_title(all_card_fields),
145
+ _one_line_text(all_card_fields, TLDR_KEY),
146
+ prompt,
147
+ )
148
+ return dict(zip(ALL_CARD_KEYS, all_card_texts))
149
+
150
+
151
+ def _parsed_card_fields(piped_text: str) -> dict[str, object]:
152
+ try:
153
+ all_card_fields = json.loads(piped_text)
154
+ except json.JSONDecodeError as error:
155
+ raise CardInputRunFatal(REASON_NOT_AN_OBJECT) from error
156
+ if not isinstance(all_card_fields, dict):
157
+ raise CardInputRunFatal(REASON_NOT_AN_OBJECT)
158
+ return all_card_fields
159
+
160
+
161
+ def _build_parser() -> argparse.ArgumentParser:
162
+ parser = argparse.ArgumentParser(description="Build a correction task card.")
163
+ parser.add_argument("--prompt-only", action="store_true")
164
+ return parser
165
+
166
+
167
+ def main(all_arguments: list[str]) -> int:
168
+ """Read the card input from standard input and print the card or its prompt.
169
+
170
+ Args:
171
+ all_arguments: the command-line arguments after the script name.
172
+
173
+ Returns:
174
+ The process exit code.
175
+ """
176
+ parsed_arguments = _build_parser().parse_args(all_arguments)
177
+ playbook_text = _fix_playbook_path().read_text(encoding=UTF8_ENCODING)
178
+ try:
179
+ all_card_fields = _parsed_card_fields(
180
+ sys.stdin.buffer.read().decode(UTF8_ENCODING)
181
+ )
182
+ task_card = build_task_card(all_card_fields, playbook_text)
183
+ except CardInputRunFatal as error:
184
+ sys.stderr.write(INVALID_INPUT_MESSAGE_TEMPLATE.format(reason=error))
185
+ return FAILURE_EXIT_CODE
186
+ if parsed_arguments.prompt_only:
187
+ sys.stdout.write(task_card["prompt"])
188
+ else:
189
+ sys.stdout.write(json.dumps(task_card) + LINE_SEPARATOR)
190
+ return SUCCESS_EXIT_CODE
191
+
192
+
193
+ if __name__ == "__main__":
194
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,38 @@
1
+ """Configuration constants for the correction task card command."""
2
+
3
+ ABSOLUTE_PATH_PATTERN = r"^(?:[/\\~]|[A-Za-z]:)"
4
+ BRIEF_KEY = "brief"
5
+ ALL_CARD_KEYS = ("title", "tldr", "prompt")
6
+ FAILURE_EXIT_CODE = 1
7
+ LINE_SEPARATOR = "\n"
8
+ FILE_LINE_TEMPLATE = "- `{path}`"
9
+ FILES_KEY = "files"
10
+ FIX_PLAYBOOK_FILE_NAME = "fix.md"
11
+ FIX_PLAYBOOK_FIRST_HANDED_OFF_STEP = "2. "
12
+ FIX_PLAYBOOK_DIRECTORY_NAME = "playbooks"
13
+ MARKDOWN_LINK_PATTERN = r"\[([^\]]+)\]\([^)]+\)"
14
+ MAXIMUM_TITLE_LENGTH = 59
15
+ NO_FILES_LINE = "- None named in the session. Step 4 finds them."
16
+ PROMPT_TEMPLATE = (
17
+ "Land this correction as one control and one pull request.\n\n"
18
+ "## Correction brief\n\n"
19
+ "{brief}\n\n"
20
+ "## Files, relative to the repository root\n\n"
21
+ "{file_lines}\n\n"
22
+ "## Fix playbook\n\n"
23
+ "The brief above is step 1. Start at step 2.\n\n"
24
+ "{playbook_steps}\n"
25
+ )
26
+ SUCCESS_EXIT_CODE = 0
27
+ TITLE_KEY = "title"
28
+ TLDR_KEY = "tldr"
29
+ UTF8_ENCODING = "utf-8"
30
+ INVALID_INPUT_MESSAGE_TEMPLATE = "Invalid card input: {reason}\n"
31
+ REASON_NOT_AN_OBJECT = "standard input must hold one JSON object"
32
+ REASON_EMPTY_FIELD_TEMPLATE = "{field} is empty"
33
+ REASON_MULTI_LINE_FIELD_TEMPLATE = "{field} must be one line"
34
+ REASON_TITLE_TOO_LONG_TEMPLATE = "title has {length} characters; the limit is {limit}"
35
+ REASON_FILES_NOT_A_LIST = "files must be a list of strings"
36
+ REASON_ABSOLUTE_FILE_TEMPLATE = (
37
+ "{path} is absolute; name it relative to the repository root"
38
+ )
@@ -0,0 +1,142 @@
1
+ """Behavior tests for correction_task_card against the shipped fix playbook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import io
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
14
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
15
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
16
+
17
+ correction_task_card = importlib.import_module("correction_task_card")
18
+
19
+ FIX_PLAYBOOK_TEXT = (SCRIPTS_DIRECTORY.parent / "playbooks" / "fix.md").read_text(
20
+ encoding="utf-8"
21
+ )
22
+
23
+
24
+ def _card_input(**overrides: object) -> dict[str, object]:
25
+ card_input: dict[str, object] = {
26
+ "title": "Add a lint for bare except blocks in hooks",
27
+ "tldr": "Agents keep writing bare except blocks in hook files.",
28
+ "brief": "Correction: stop writing bare except\nEvidence: hooks/example.py:12",
29
+ "files": ["packages/claude-dev-env/hooks/blocking/example.py"],
30
+ }
31
+ card_input.update(overrides)
32
+ return card_input
33
+
34
+
35
+ def _run_main(
36
+ card_input: object, all_arguments: list[str], monkeypatch: pytest.MonkeyPatch
37
+ ) -> int:
38
+ piped_bytes = json.dumps(card_input).encode("utf-8")
39
+ monkeypatch.setattr(
40
+ sys, "stdin", io.TextIOWrapper(io.BytesIO(piped_bytes), encoding="utf-8")
41
+ )
42
+ return correction_task_card.main(all_arguments)
43
+
44
+
45
+ def test_should_print_the_three_spawn_task_fields(
46
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
47
+ ) -> None:
48
+ assert _run_main(_card_input(), [], monkeypatch) == 0
49
+ task_card = json.loads(capsys.readouterr().out)
50
+ assert list(task_card) == ["title", "tldr", "prompt"]
51
+ assert task_card["title"] == "Add a lint for bare except blocks in hooks"
52
+ assert task_card["tldr"] == "Agents keep writing bare except blocks in hook files."
53
+
54
+
55
+ def test_should_carry_the_brief_files_and_playbook_steps_in_the_prompt() -> None:
56
+ prompt = correction_task_card.build_task_card(_card_input(), FIX_PLAYBOOK_TEXT)[
57
+ "prompt"
58
+ ]
59
+ assert (
60
+ "Correction: stop writing bare except\nEvidence: hooks/example.py:12" in prompt
61
+ )
62
+ assert "- `packages/claude-dev-env/hooks/blocking/example.py`" in prompt
63
+ assert "Start at step 2." in prompt
64
+ for each_step_number in range(2, 9):
65
+ assert f"\n{each_step_number}. " in prompt
66
+ assert "**Reply:**" in prompt
67
+
68
+
69
+ def test_should_leave_out_step_one_and_relative_links() -> None:
70
+ prompt = correction_task_card.build_task_card(_card_input(), FIX_PLAYBOOK_TEXT)[
71
+ "prompt"
72
+ ]
73
+ assert "\n1. Write the brief" not in prompt
74
+ assert "](../" not in prompt
75
+ assert "`correction-lens.md`" in prompt
76
+
77
+
78
+ def test_should_print_only_the_prompt_for_the_handoff_brief(
79
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
80
+ ) -> None:
81
+ assert _run_main(_card_input(), ["--prompt-only"], monkeypatch) == 0
82
+ printed = capsys.readouterr().out
83
+ assert (
84
+ printed
85
+ == correction_task_card.build_task_card(_card_input(), FIX_PLAYBOOK_TEXT)[
86
+ "prompt"
87
+ ]
88
+ )
89
+
90
+
91
+ def test_should_name_no_files_when_the_session_names_none() -> None:
92
+ prompt = correction_task_card.build_task_card(
93
+ _card_input(files=[]), FIX_PLAYBOOK_TEXT
94
+ )["prompt"]
95
+ assert "- None named in the session. Step 4 finds them." in prompt
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ ("overrides", "reason_fragment"),
100
+ [
101
+ ({"title": "x" * 60}, "title has 60 characters; the limit is 59"),
102
+ ({"title": " "}, "title is empty"),
103
+ ({"title": "Add a lint\nfor hooks"}, "title must be one line"),
104
+ ({"tldr": "first line\nsecond line"}, "tldr must be one line"),
105
+ ({"brief": ""}, "brief is empty"),
106
+ ({"files": "hooks/example.py"}, "files must be a list of strings"),
107
+ ({"files": ["/home/me/repo/hooks/example.py"]}, "is absolute"),
108
+ ({"files": ["C:\\repo\\hooks\\example.py"]}, "is absolute"),
109
+ ({"files": ["~/repo/hooks/example.py"]}, "is absolute"),
110
+ ({"files": [" /home/me/repo/hooks/example.py"]}, "is absolute"),
111
+ ],
112
+ )
113
+ def test_should_refuse_a_card_that_breaks_a_field_rule(
114
+ overrides: dict[str, object],
115
+ reason_fragment: str,
116
+ monkeypatch: pytest.MonkeyPatch,
117
+ capsys: pytest.CaptureFixture[str],
118
+ ) -> None:
119
+ assert _run_main(_card_input(**overrides), [], monkeypatch) == 1
120
+ captured = capsys.readouterr()
121
+ assert captured.out == ""
122
+ assert reason_fragment in captured.err
123
+
124
+
125
+ def test_should_accept_a_title_at_the_length_limit() -> None:
126
+ task_card = correction_task_card.build_task_card(
127
+ _card_input(title="A" * 59), FIX_PLAYBOOK_TEXT
128
+ )
129
+ assert len(task_card["title"]) == 59
130
+
131
+
132
+ def test_should_refuse_input_that_is_not_one_json_object(
133
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
134
+ ) -> None:
135
+ assert _run_main(["a", "list"], [], monkeypatch) == 1
136
+ assert "standard input must hold one JSON object" in capsys.readouterr().err
137
+
138
+
139
+ def test_should_return_the_playbook_from_step_two() -> None:
140
+ handed_off_steps = correction_task_card.handed_off_playbook_steps(FIX_PLAYBOOK_TEXT)
141
+ assert handed_off_steps.startswith("2. ")
142
+ assert "pull request link" in handed_off_steps
@@ -95,9 +95,24 @@ def test_dispatch_writes_nothing_when_no_hosted_hook_printed_context(
95
95
  assert capsys.readouterr().out == ""
96
96
 
97
97
 
98
+ def test_additional_context_from_hook_output_reads_context_under_hook_specific_output() -> None:
99
+ captured_stdout = json.dumps(
100
+ {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "PR #7"}}
101
+ )
102
+ assert additional_context_from_hook_output(captured_stdout) == "PR #7"
103
+
104
+
98
105
  @pytest.mark.parametrize(
99
106
  "captured_stdout",
100
- ["", " ", "not json", "[]", '{"decision": "block"}', '{"hookSpecificOutput": {}}'],
107
+ [
108
+ "",
109
+ " ",
110
+ "not json",
111
+ "[]",
112
+ '{"decision": "block"}',
113
+ '{"hookSpecificOutput": {}}',
114
+ '{"additionalContext": "PR #7"}',
115
+ ],
101
116
  )
102
117
  def test_additional_context_from_hook_output_ignores_non_context_output(
103
118
  captured_stdout: str,
@@ -140,6 +140,14 @@ def test_unreadable_or_missing_transcript_allows_silently(tmp_path: Path) -> Non
140
140
  assert _run_main(payload) == (0, "")
141
141
 
142
142
 
143
+ def test_unreadable_agent_transcript_beside_an_unloaded_session_allows_silently(
144
+ tmp_path: Path,
145
+ ) -> None:
146
+ payload = _payload("git push", _transcript(tmp_path))
147
+ payload["agent_transcript_path"] = str(tmp_path / "missing-agent.jsonl")
148
+ assert _run_main(payload) == (0, "")
149
+
150
+
143
151
  def test_action_command_shapes(tmp_path: Path) -> None:
144
152
  path = _transcript(tmp_path)
145
153
  commands = (
@@ -653,3 +653,19 @@ def test_should_keep_the_hedge_word_when_trimming_a_long_sentence(
653
653
  assert quoted_sentence.startswith("...")
654
654
  assert quoted_sentence.endswith("...")
655
655
  assert len(quoted_sentence) <= 166
656
+
657
+
658
+ @pytest.mark.parametrize(
659
+ "stdin_text",
660
+ ["not json", "[]", json.dumps({"tool_input": WRITE_INPUT, "tool_use_id": TOOL_USE_ID})],
661
+ )
662
+ def test_should_allow_silently_when_the_hook_input_names_no_tool(
663
+ monkeypatch: pytest.MonkeyPatch,
664
+ capsys: pytest.CaptureFixture[str],
665
+ tmp_path: Path,
666
+ stdin_text: str,
667
+ ) -> None:
668
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(stdin_text.encode("utf-8"))))
669
+ exit_code = verify_before_acting.main()
670
+ assert (exit_code, capsys.readouterr().out) == (0, "")
671
+ assert logged_outcomes(tmp_path) == []
@@ -115,6 +115,32 @@ def test_should_carry_child_session_start_output_as_additional_context(
115
115
  assert additional_context.index("alpha") < additional_context.index("beta")
116
116
 
117
117
 
118
+ def test_should_carry_only_the_context_of_a_child_session_start_json_output(
119
+ tmp_path: pathlib.Path,
120
+ ) -> None:
121
+ child_output = {
122
+ "hookSpecificOutput": {
123
+ "hookEventName": "SessionStart",
124
+ "additionalContext": "alpha loaded its rules",
125
+ }
126
+ }
127
+ _make_checkout(
128
+ tmp_path,
129
+ "alpha",
130
+ {"SessionStart": [_command_group("", f"echo '{json.dumps(child_output)}'")]},
131
+ )
132
+
133
+ completed_process = _run_hook(tmp_path, _session_start_payload())
134
+
135
+ assert completed_process.returncode == 0
136
+ assert json.loads(completed_process.stdout) == {
137
+ "hookSpecificOutput": {
138
+ "hookEventName": "SessionStart",
139
+ "additionalContext": "alpha loaded its rules",
140
+ }
141
+ }
142
+
143
+
118
144
  def test_should_block_a_tool_call_when_a_child_hook_exits_two(
119
145
  tmp_path: pathlib.Path,
120
146
  ) -> None:
@@ -317,6 +317,20 @@ def test_should_read_a_workflow_script_from_its_path(tmp_path: Path) -> None:
317
317
  assert log_record["outcome"] == "scope_settled"
318
318
 
319
319
 
320
+ def test_should_remind_a_workflow_whose_script_path_cannot_be_read(tmp_path: Path) -> None:
321
+ stdout = _run_hook(
322
+ tmp_path,
323
+ {
324
+ "tool_name": "Workflow",
325
+ "tool_input": {"scriptPath": str(tmp_path / "absent.js")},
326
+ "transcript_path": str(_write_transcript(tmp_path, [_user("Fix it."), READ_STEP])),
327
+ },
328
+ )
329
+ assert _reminder(stdout) == MISSING_INTERVIEW_REASON
330
+ [log_record] = _decision_log(tmp_path)
331
+ assert log_record["outcome"] == "reminded"
332
+
333
+
320
334
  @pytest.mark.parametrize(
321
335
  "tool_input",
322
336
  [
@@ -94,6 +94,18 @@ def test_should_name_no_sonnet_model_in_the_reshaped_spawn(tmp_path: Path) -> No
94
94
  assert "sonnet" not in stdout.lower()
95
95
 
96
96
 
97
+ def test_should_quote_a_capped_pace_verdict_in_the_reshape_context(tmp_path: Path) -> None:
98
+ long_verdict = {"over_pace": True, "detail": "x" * 1000}
99
+ decision = _hook_specific_output(
100
+ _run_hook(_write_pace_stub(tmp_path, 0, long_verdict), SPAWN_INPUT)
101
+ )
102
+ context_prefix = (
103
+ "Usage over pace or unreadable: this thread spawn now runs on "
104
+ "claude-opus-5-5 at low effort. Pace verdict: "
105
+ )
106
+ assert decision["additionalContext"] == context_prefix + json.dumps(long_verdict)[:600]
107
+
108
+
97
109
  @pytest.mark.parametrize(
98
110
  ("tool_input", "reason"),
99
111
  [
@@ -291,6 +291,23 @@ class TestReminderFor:
291
291
  == COMPACTION_REMINDER
292
292
  )
293
293
 
294
+ def test_session_start_after_compaction_reminds_a_session_that_reloaded_the_skill(
295
+ self, tmp_path: Path
296
+ ) -> None:
297
+ transcript_path = _write_transcript(
298
+ tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE]
299
+ )
300
+ assert (
301
+ reminder.reminder_for(
302
+ {
303
+ "hook_event_name": "SessionStart",
304
+ "source": "compact",
305
+ "transcript_path": str(transcript_path),
306
+ }
307
+ )
308
+ == COMPACTION_REMINDER
309
+ )
310
+
294
311
  def test_session_start_after_compaction_without_a_prior_skill_call_prints_nothing(
295
312
  self, tmp_path: Path
296
313
  ) -> None:
@@ -9,12 +9,17 @@ import sys
9
9
  from hooks_constants.harness_scratchpad_constants import (
10
10
  CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME,
11
11
  )
12
+ from hooks_constants.headless_claude_broker_gate_constants import CLOUD_SESSION_ENV_VAR
12
13
 
13
14
 
14
15
  def test_live_session_id_is_absent_inside_each_test() -> None:
15
16
  assert CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME not in os.environ
16
17
 
17
18
 
19
+ def test_cloud_session_marker_is_absent_inside_each_test() -> None:
20
+ assert CLOUD_SESSION_ENV_VAR not in os.environ
21
+
22
+
18
23
  def test_hook_subprocess_inherits_no_live_session_id() -> None:
19
24
  completed = subprocess.run(
20
25
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.74.2",
3
+ "version": "8.75.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {