claude-dev-env 8.97.0 → 8.98.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.
Files changed (35) hide show
  1. package/bin/install.prompt-context.test.mjs +3 -2
  2. package/bin/install.settings-defaults.test.mjs +4 -3
  3. package/docs/agents-guidance/asks.md +6 -1
  4. package/hooks/blocking/open_tasks_stop_gate.py +138 -34
  5. package/hooks/blocking/open_tasks_transcript.py +112 -5
  6. package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
  7. package/hooks/blocking/python_windows_invocation_gate.py +249 -0
  8. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  9. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  10. package/hooks/blocking/test_open_tasks_stop_gate.py +215 -0
  11. package/hooks/blocking/test_open_tasks_transcript.py +89 -0
  12. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  13. package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
  14. package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
  15. package/hooks/blocking/verify_before_commit_gate.py +278 -0
  16. package/hooks/features/bash-dispatchers.md +3 -0
  17. package/hooks/features/conduct-gates.md +10 -2
  18. package/hooks/hooks.json +15 -0
  19. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  20. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
  21. package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
  22. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  23. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  24. package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
  25. package/hooks/open_task_asks.py +163 -0
  26. package/hooks/session/open_task_ask_recorder.py +38 -0
  27. package/hooks/session/test_open_task_ask_recorder.py +57 -0
  28. package/hooks/test_open_task_asks.py +52 -0
  29. package/hooks/test_transcript_skill_scan.py +24 -0
  30. package/hooks/transcript_skill_scan.py +54 -3
  31. package/package.json +1 -1
  32. package/scripts/policy_lint/adapter_configuration.py +5 -0
  33. package/scripts/policy_lint/config/constants.py +1 -0
  34. package/scripts/tests/test_adapter_configuration.py +8 -0
  35. package/settings.json +2 -1
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: stop two Python invocations that fail on Windows.
3
+
4
+ ``python -I`` turns on isolated mode, which ignores every ``PYTHON*``
5
+ environment variable, ``PYTHONUTF8`` included. On Windows the interpreter then
6
+ writes with the console code page, and non-ASCII output stops with
7
+ ``UnicodeEncodeError: 'charmap'``. On a Windows host a command segment is
8
+ denied when it runs ``python``, ``python3`` or ``py`` with ``-I`` and without
9
+ ``-X utf8``. The option scan reads clusters, so ``-IX utf8``, ``-IXutf8`` and
10
+ ``-I -X utf8`` all pass. It stops at the script name, ``-c`` or ``-m``, so an
11
+ ``-I`` meant for the script passes. Linux runs ``-I`` in UTF-8 already, so the
12
+ check stays quiet there.
13
+
14
+ Under the PowerShell tool a command is also denied when a ``python -c "..."``
15
+ argument holds ``\\"``. PowerShell reads the backslash as a plain character and
16
+ ends the string at that quote. The deny reason names a script file or a
17
+ single-quoted here-string. Bash reads ``\\"`` as an escaped quote, so the Bash
18
+ tool passes it.
19
+
20
+ Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
21
+ PowerShell tools.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import sys
29
+ from collections.abc import Iterator
30
+ from pathlib import Path
31
+
32
+ try:
33
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
34
+ if _hooks_root_directory not in sys.path:
35
+ sys.path.insert(0, _hooks_root_directory)
36
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
37
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
38
+ DENY_DECISION,
39
+ HOOK_EVENT_NAME,
40
+ POWERSHELL_TOOL_NAME,
41
+ REASON_JOIN_SEPARATOR,
42
+ )
43
+ from hooks_constants.hook_block_logger import log_hook_block
44
+ from hooks_constants.piped_pytest_blocker_constants import (
45
+ PYTHON_INTERPRETER_BASENAME_PATTERN,
46
+ )
47
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
48
+ from hooks_constants.python_windows_invocation_gate_constants import (
49
+ ALL_COMMAND_ENDING_OPTION_LETTERS,
50
+ ALL_UTF8_MODE_OPTION_VALUES,
51
+ ALL_VALUE_LONG_OPTIONS,
52
+ ALL_VALUE_OPTION_LETTERS,
53
+ END_OF_OPTIONS_MARKER,
54
+ GATE_HOOK_NAME,
55
+ IMPLEMENTATION_OPTION_LETTER,
56
+ ISOLATED_MODE_OPTION_LETTER,
57
+ ISOLATED_PYTHON_DENY_REASON,
58
+ OPTION_PREFIX,
59
+ POWERSHELL_BACKSLASH_QUOTE_DENY_REASON,
60
+ PYTHON_CODE_ARGUMENT_BACKSLASH_QUOTE_PATTERN,
61
+ WINDOWS_OS_NAME,
62
+ )
63
+ from hooks_constants.shell_command_wrappers import (
64
+ all_wrapped_command_segments,
65
+ segment_program_and_arguments,
66
+ )
67
+ except ImportError as import_error:
68
+ raise ImportError(
69
+ "The Python Windows invocation gate cannot import its dependencies; "
70
+ "ensure the hooks directory is importable."
71
+ ) from import_error
72
+
73
+
74
+ def is_windows_host() -> bool:
75
+ """Return True when the hook runs on Windows, where the shell commands run too."""
76
+ return os.name == WINDOWS_OS_NAME
77
+
78
+
79
+ def _is_short_option_cluster(argument: str) -> bool:
80
+ return (
81
+ argument.startswith(OPTION_PREFIX)
82
+ and not argument.startswith(END_OF_OPTIONS_MARKER)
83
+ and argument != OPTION_PREFIX
84
+ )
85
+
86
+
87
+ def _is_long_option(argument: str) -> bool:
88
+ return argument.startswith(END_OF_OPTIONS_MARKER) and argument != END_OF_OPTIONS_MARKER
89
+
90
+
91
+ def _skip_long_option_value(argument: str, remaining_arguments: Iterator[str]) -> None:
92
+ if argument in ALL_VALUE_LONG_OPTIONS:
93
+ next(remaining_arguments, "")
94
+
95
+
96
+ def _cluster_options(
97
+ cluster_letters: str, remaining_arguments: Iterator[str]
98
+ ) -> list[tuple[str, str]]:
99
+ """Return each option letter in one short-option cluster with its value.
100
+
101
+ A letter that takes a value uses the rest of the cluster, or the next
102
+ argument when the rest is empty, so ``-IXutf8`` and ``-IX utf8`` both read
103
+ as ``I`` and ``X`` with the value ``utf8``.
104
+ """
105
+ all_options: list[tuple[str, str]] = []
106
+ for each_index, each_letter in enumerate(cluster_letters):
107
+ if each_letter not in ALL_VALUE_OPTION_LETTERS:
108
+ all_options.append((each_letter, ""))
109
+ continue
110
+ inline_value = cluster_letters[each_index + 1 :]
111
+ all_options.append((each_letter, inline_value or next(remaining_arguments, "")))
112
+ return all_options
113
+ return all_options
114
+
115
+
116
+ def _interpreter_options(all_arguments: list[str]) -> list[tuple[str, str]]:
117
+ """Return the interpreter option letters and values ahead of the script, ``-c`` or ``-m``.
118
+
119
+ Args:
120
+ all_arguments: The tokens after the interpreter program name.
121
+ """
122
+ all_options: list[tuple[str, str]] = []
123
+ remaining_arguments = iter(all_arguments)
124
+ for each_argument in remaining_arguments:
125
+ if _is_long_option(each_argument):
126
+ _skip_long_option_value(each_argument, remaining_arguments)
127
+ continue
128
+ if not _is_short_option_cluster(each_argument):
129
+ break
130
+ all_options.extend(
131
+ _cluster_options(each_argument[len(OPTION_PREFIX) :], remaining_arguments)
132
+ )
133
+ if all_options[-1][0] in ALL_COMMAND_ENDING_OPTION_LETTERS:
134
+ break
135
+ return all_options
136
+
137
+
138
+ def _isolated_without_utf8_mode(all_arguments: list[str]) -> bool:
139
+ """Return True when the interpreter options set ``-I`` and leave out ``-X utf8``.
140
+
141
+ Args:
142
+ all_arguments: The tokens after the interpreter program name.
143
+ """
144
+ all_options = _interpreter_options(all_arguments)
145
+ is_isolated = any(
146
+ each_letter == ISOLATED_MODE_OPTION_LETTER for each_letter, _ in all_options
147
+ )
148
+ has_utf8_mode = any(
149
+ each_letter == IMPLEMENTATION_OPTION_LETTER
150
+ and each_value in ALL_UTF8_MODE_OPTION_VALUES
151
+ for each_letter, each_value in all_options
152
+ )
153
+ return is_isolated and not has_utf8_mode
154
+
155
+
156
+ def runs_isolated_python_without_utf8_mode(command: str) -> bool:
157
+ """Return True when any command segment runs Python with ``-I`` and without ``-X utf8``.
158
+
159
+ ::
160
+
161
+ python -I script.py -> True
162
+ py -3 -I -c pass -> True
163
+ python -IX utf8 script.py -> False
164
+ python -I -X utf8 script.py -> False
165
+ python script.py -I -> False
166
+
167
+ Args:
168
+ command: The shell command text the agent is about to run.
169
+ """
170
+ for each_segment in all_wrapped_command_segments(command):
171
+ program_name, all_arguments = segment_program_and_arguments(each_segment)
172
+ if PYTHON_INTERPRETER_BASENAME_PATTERN.fullmatch(program_name.lower()) is None:
173
+ continue
174
+ if _isolated_without_utf8_mode(all_arguments):
175
+ return True
176
+ return False
177
+
178
+
179
+ def has_backslash_quote_in_python_code_argument(command: str) -> bool:
180
+ """Return True when a ``python -c "..."`` argument holds ``\\"`` in the raw command text.
181
+
182
+ ::
183
+
184
+ python -c "print(\\"x\\")" -> True
185
+ python -c 'print("x")' -> False
186
+
187
+ Args:
188
+ command: The shell command text the agent is about to run.
189
+ """
190
+ return PYTHON_CODE_ARGUMENT_BACKSLASH_QUOTE_PATTERN.search(command) is not None
191
+
192
+
193
+ def all_deny_reasons(command: str, tool_name: str) -> list[str]:
194
+ """Return the deny reason for each Windows Python failure the command would hit.
195
+
196
+ Args:
197
+ command: The shell command text the agent is about to run.
198
+ tool_name: The tool that runs the command, Bash or PowerShell.
199
+ """
200
+ all_reasons: list[str] = []
201
+ if is_windows_host() and runs_isolated_python_without_utf8_mode(command):
202
+ all_reasons.append(ISOLATED_PYTHON_DENY_REASON)
203
+ if (
204
+ tool_name == POWERSHELL_TOOL_NAME
205
+ and has_backslash_quote_in_python_code_argument(command)
206
+ ):
207
+ all_reasons.append(POWERSHELL_BACKSLASH_QUOTE_DENY_REASON)
208
+ return all_reasons
209
+
210
+
211
+ def _write_deny_decision(reason: str) -> None:
212
+ sys.stdout.write(
213
+ json.dumps(
214
+ {
215
+ "hookSpecificOutput": {
216
+ "hookEventName": HOOK_EVENT_NAME,
217
+ "permissionDecision": DENY_DECISION,
218
+ "permissionDecisionReason": reason,
219
+ }
220
+ }
221
+ )
222
+ )
223
+ sys.stdout.flush()
224
+
225
+
226
+ def main() -> None:
227
+ """Deny a Python invocation that fails on Windows, or stay quiet."""
228
+ hook_payload = read_hook_input_dictionary_from_stdin()
229
+ if hook_payload is None:
230
+ return
231
+ tool_name = hook_payload.get("tool_name")
232
+ if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
233
+ return
234
+ tool_input = hook_payload.get("tool_input")
235
+ if not isinstance(tool_input, dict):
236
+ return
237
+ command = tool_input.get("command", "")
238
+ if not isinstance(command, str):
239
+ return
240
+ all_reasons = all_deny_reasons(command, str(tool_name))
241
+ if not all_reasons:
242
+ return
243
+ reason = REASON_JOIN_SEPARATOR.join(all_reasons)
244
+ log_hook_block(GATE_HOOK_NAME, HOOK_EVENT_NAME, reason, str(tool_name), command)
245
+ _write_deny_decision(reason)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
22
22
  )
23
23
 
24
24
 
25
- def test_bash_roster_starts_only_the_msys_rewriter_and_the_three_gates() -> None:
25
+ def test_bash_roster_starts_only_the_msys_rewriter_and_the_four_gates() -> None:
26
26
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
27
27
  BashHostedHookEntry(
28
28
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -42,4 +42,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_three_gates() -> None
42
42
  script_relative_path="blocking/cloud_graphql_gate.py",
43
43
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
44
44
  ),
45
+ BashHostedHookEntry(
46
+ script_relative_path="blocking/python_windows_invocation_gate.py",
47
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
48
+ ),
45
49
  )
@@ -273,6 +273,7 @@ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
273
273
  assert all_script_paths == [
274
274
  "blocking/msys_rev_path_rewriter.py",
275
275
  "blocking/cloud_graphql_gate.py",
276
+ "blocking/python_windows_invocation_gate.py",
276
277
  ]
277
278
 
278
279
 
@@ -1,3 +1,4 @@
1
+ import importlib
1
2
  import io
2
3
  import json
3
4
  import subprocess
@@ -295,3 +296,217 @@ def test_should_allow_a_cursor_stop_after_five_followups(
295
296
  transcript_path = write_cursor_transcript(tmp_path / "cursor.jsonl", [cursor_todo_record(all_todos, False, "Done.")])
296
297
  assert "rotate the deploy key" in run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=4)["followup_message"]
297
298
  assert run_cursor_gate(monkeypatch, capsys, transcript_path, loop_count=5) == {}
299
+
300
+
301
+ def ccd_plan_record(all_plan_input: dict[str, object]) -> dict[str, object]:
302
+ tool_use = {"type": "tool_use", "id": "p1", "name": "mcp__ccd_turn__update_plan", "input": all_plan_input}
303
+ return {"type": "assistant", "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
304
+
305
+
306
+ def assistant_text_record(reply_text: str) -> dict[str, object]:
307
+ return {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": reply_text}]}}
308
+
309
+
310
+ def write_records(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
311
+ transcript_path.write_text("\n".join(json.dumps(each_record) for each_record in all_records), encoding="utf-8")
312
+ return transcript_path
313
+
314
+
315
+ def block_reason(
316
+ monkeypatch: pytest.MonkeyPatch,
317
+ capsys: pytest.CaptureFixture[str],
318
+ reply_text: str,
319
+ transcript_path: Path | None = None,
320
+ ) -> str:
321
+ _, stdout_text = run_gate(monkeypatch, capsys, reply_text, transcript_path)
322
+ return json.loads(stdout_text)["reason"] if stdout_text else ""
323
+
324
+
325
+ def record_ask(prompt_text: str) -> str | None:
326
+ return importlib.import_module("open_task_asks").record_prompt(SESSION_ID, prompt_text)
327
+
328
+
329
+ def submit_and_replace_list(tmp_path: Path) -> Path:
330
+ return write_records(
331
+ tmp_path / "s.jsonl",
332
+ [
333
+ ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
334
+ ccd_plan_record({"items": [{"content": "Set rows in Neon", "status": "completed"}], "scope": "continuation"}),
335
+ ],
336
+ )
337
+
338
+
339
+ def test_should_hold_an_interrupted_ask_after_the_user_redirects(
340
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
341
+ ) -> None:
342
+ assert record_ask("Export the OneUI8 and OneUI9 APKs and submit them") == "ask-1"
343
+ assert record_ask("Set the 8 rows to Ready to Export in Neon") == "ask-2"
344
+ transcript_path = write_records(
345
+ tmp_path / "s.jsonl",
346
+ [ccd_plan_record({"items": [{"content": "Set the 8 rows in Neon", "status": "completed"}], "scope": "continuation"})],
347
+ )
348
+ reason_text = block_reason(monkeypatch, capsys, "Rows set.\nDone: #ask-2", transcript_path)
349
+ assert "#ask-1" in reason_text
350
+ assert "OneUI9" in reason_text
351
+ assert "#ask-2" not in reason_text
352
+
353
+
354
+ def test_should_hold_plan_items_a_continuation_list_dropped(
355
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
356
+ ) -> None:
357
+ first_items = [
358
+ {"content": "Export the OneUI8 APK", "status": "in_progress"},
359
+ {"content": "Submit both APKs", "status": "pending"},
360
+ ]
361
+ transcript_path = write_records(
362
+ tmp_path / "s.jsonl",
363
+ [
364
+ ccd_plan_record({"items": first_items, "scope": "first_task"}),
365
+ ccd_plan_record({"scope": "continuation", "items": [{"content": "Set rows in Neon", "status": "completed"}]}),
366
+ ],
367
+ )
368
+ reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
369
+ assert "Export the OneUI8 APK" in reason_text
370
+ assert "Submit both APKs" in reason_text
371
+ assert "Set rows in Neon" not in reason_text
372
+
373
+
374
+ def test_should_hold_an_ask_that_never_reached_a_plan(
375
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
376
+ ) -> None:
377
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
378
+ reason_text = block_reason(monkeypatch, capsys, "Done.")
379
+ assert "#ask-1: Export the OneUI8 and OneUI9 APKs and submit them" in reason_text
380
+ assert "Done: #ask-N" in reason_text
381
+
382
+
383
+ def test_should_merge_the_task_store_with_the_ccd_plan(
384
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
385
+ ) -> None:
386
+ write_task_store(tmp_path, [{"id": "1", "subject": "Rotate the deploy key", "status": "pending"}])
387
+ transcript_path = write_records(
388
+ tmp_path / "s.jsonl",
389
+ [ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
390
+ )
391
+ reason_text = block_reason(monkeypatch, capsys, "Done.", transcript_path)
392
+ assert "Rotate the deploy key" in reason_text
393
+ assert "Port the hook" in reason_text
394
+
395
+
396
+ def test_should_hold_an_ask_whose_subject_matches_a_completed_task(
397
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
398
+ ) -> None:
399
+ write_task_store(tmp_path, [{"id": "1", "subject": "Fix login", "status": "completed"}])
400
+ assert record_ask("Fix login") == "ask-1"
401
+ reason_text = block_reason(monkeypatch, capsys, "Done.")
402
+ assert "#ask-1: Fix login" in reason_text
403
+
404
+
405
+ def test_should_hold_a_pending_task_that_shares_a_subject_with_a_completed_task(
406
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
407
+ ) -> None:
408
+ write_task_store(
409
+ tmp_path,
410
+ [
411
+ {"id": "1", "subject": "Run tests", "status": "completed"},
412
+ {"id": "2", "subject": "Run tests", "status": "pending"},
413
+ ],
414
+ )
415
+ assert "Run tests" in block_reason(monkeypatch, capsys, "Done.")
416
+
417
+
418
+ def test_should_record_nothing_for_a_question_only_prompt(
419
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
420
+ ) -> None:
421
+ assert record_ask("wait, did the export fail?") is None
422
+ assert record_ask("are you monitoring these? yes or no") is None
423
+ assert run_gate(monkeypatch, capsys, "No, it passed.") == (0, "")
424
+
425
+
426
+ def test_should_not_close_a_plan_item_on_a_done_line(
427
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
428
+ ) -> None:
429
+ transcript_path = write_records(
430
+ tmp_path / "s.jsonl",
431
+ [ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
432
+ )
433
+ assert "Port the hook" in block_reason(monkeypatch, capsys, "Done: Port the hook", transcript_path)
434
+
435
+
436
+ def test_should_close_an_ask_and_a_carried_item_on_a_dropped_line_quoting_the_user(
437
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
438
+ ) -> None:
439
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
440
+ transcript_path = submit_and_replace_list(tmp_path)
441
+ record_ask("Skip the APK export for now, set the rows instead")
442
+ reply_text = (
443
+ "Rows set.\nDone: #ask-2\n"
444
+ 'Dropped: #ask-1 - "skip the APK export for now"\n'
445
+ 'Dropped: Submit both APKs - "Skip the APK export"'
446
+ )
447
+ assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
448
+ with transcript_path.open("a", encoding="utf-8") as transcript_handle:
449
+ transcript_handle.write("\n" + json.dumps(assistant_text_record(reply_text)))
450
+ assert run_gate(monkeypatch, capsys, "Nothing else.", transcript_path) == (0, "")
451
+
452
+
453
+ def test_should_hold_a_dropped_line_whose_quote_the_user_never_wrote(
454
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
455
+ ) -> None:
456
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
457
+ transcript_path = submit_and_replace_list(tmp_path)
458
+ record_ask("Set the rows next")
459
+ reply_text = 'Dropped: #ask-1 - "the user cancelled the export"\nDropped: Submit both APKs - "skip it, it is done"'
460
+ reason_text = block_reason(monkeypatch, capsys, reply_text, transcript_path)
461
+ assert "#ask-1" in reason_text
462
+ assert "Submit both APKs" in reason_text
463
+
464
+
465
+ def test_should_hold_a_dropped_line_quoting_the_ask_itself(
466
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
467
+ ) -> None:
468
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
469
+ reason_text = block_reason(monkeypatch, capsys, 'Dropped: #ask-1 - "Export the OneUI8"')
470
+ assert "#ask-1" in reason_text
471
+
472
+
473
+ def test_should_hold_an_ask_again_when_the_next_reply_omits_its_open_line(
474
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
475
+ ) -> None:
476
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
477
+ first_reply = "Open: #ask-1 - the export job runs next"
478
+ assert run_gate(monkeypatch, capsys, first_reply) == (0, "")
479
+ transcript_path = write_records(tmp_path / "s.jsonl", [assistant_text_record(first_reply)])
480
+ assert "#ask-1" in block_reason(monkeypatch, capsys, "Still running.", transcript_path)
481
+
482
+
483
+ def test_should_allow_the_stop_once_a_carried_item_is_listed_completed(
484
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
485
+ ) -> None:
486
+ transcript_path = submit_and_replace_list(tmp_path)
487
+ assert "Submit both APKs" in block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
488
+ with transcript_path.open("a", encoding="utf-8") as transcript_handle:
489
+ relisted = ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "completed"}], "scope": "continuation"})
490
+ transcript_handle.write("\n" + json.dumps(relisted))
491
+ assert run_gate(monkeypatch, capsys, "Both submitted.", transcript_path) == (0, "")
492
+
493
+
494
+ def test_should_mark_the_right_item_for_a_done_index_after_a_continuation(
495
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
496
+ ) -> None:
497
+ replacing_items = [
498
+ {"content": "Read the schema", "status": "in_progress"},
499
+ {"content": "Set rows in Neon", "status": "pending"},
500
+ ]
501
+ transcript_path = write_records(
502
+ tmp_path / "s.jsonl",
503
+ [
504
+ ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
505
+ ccd_plan_record({"items": replacing_items, "scope": "continuation"}),
506
+ ccd_plan_record({"done": [1, 2]}),
507
+ ],
508
+ )
509
+ reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
510
+ assert "Submit both APKs" in reason_text
511
+ assert "Read the schema" not in reason_text
512
+ assert "Set rows in Neon" not in reason_text
@@ -52,3 +52,92 @@ def test_should_parse_only_json_objects() -> None:
52
52
  assert parsed_object('{"a": 1}') == {"a": 1}
53
53
  assert parsed_object("[1]") is None
54
54
  assert parsed_object("not json") is None
55
+
56
+
57
+ def ccd_plan_record(all_plan_input: dict[str, object], record_type: str = "assistant") -> dict[str, object]:
58
+ tool_use = {"type": "tool_use", "id": "p1", "name": "mcp__ccd_turn__update_plan", "input": all_plan_input}
59
+ return {"type": record_type, "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
60
+
61
+
62
+ def plan_statuses(transcript_path: Path) -> list[tuple[str, str, bool]]:
63
+ all_items = replayed_transcript(str(transcript_path)).items() or []
64
+ return [(each_item.subject, each_item.status, each_item.is_carried) for each_item in all_items]
65
+
66
+
67
+ def test_should_replay_ccd_turn_advance_once_and_skip_request_blob_copies(tmp_path: Path) -> None:
68
+ all_items = [
69
+ {"content": "Read the gate", "status": "in_progress"},
70
+ {"content": "Write the tests", "status": "pending"},
71
+ {"content": "Open the PR", "status": "pending"},
72
+ ]
73
+ transcript_path = write_transcript(
74
+ tmp_path / "ccd.jsonl",
75
+ [
76
+ ccd_plan_record({"items": all_items, "scope": "first_task"}),
77
+ ccd_plan_record({"advance": True}),
78
+ ccd_plan_record({"advance": True}, record_type="api-request-blob"),
79
+ ],
80
+ )
81
+ assert plan_statuses(transcript_path) == [
82
+ ("Read the gate", "completed", False),
83
+ ("Write the tests", "in_progress", False),
84
+ ("Open the PR", "pending", False),
85
+ ]
86
+
87
+
88
+ def test_should_carry_unfinished_items_after_the_replacing_list(tmp_path: Path) -> None:
89
+ first_items = [
90
+ {"content": "Export the OneUI8 APK", "status": "in_progress"},
91
+ {"content": "Submit both APKs", "status": "pending"},
92
+ {"content": "Find the rows", "status": "completed"},
93
+ ]
94
+ transcript_path = write_transcript(
95
+ tmp_path / "ccd.jsonl",
96
+ [
97
+ ccd_plan_record({"items": first_items, "scope": "first_task"}),
98
+ ccd_plan_record({"scope": "continuation", "items": [{"content": "Set rows in Neon", "status": "in_progress"}]}),
99
+ ],
100
+ )
101
+ assert plan_statuses(transcript_path) == [
102
+ ("Set rows in Neon", "in_progress", False),
103
+ ("Export the OneUI8 APK", "in_progress", True),
104
+ ("Submit both APKs", "pending", True),
105
+ ]
106
+
107
+
108
+ def test_should_point_done_and_start_indices_at_the_tools_own_items(tmp_path: Path) -> None:
109
+ transcript_path = write_transcript(
110
+ tmp_path / "ccd.jsonl",
111
+ [
112
+ ccd_plan_record({"items": [{"content": "Alpha", "status": "pending"}], "scope": "first_task"}),
113
+ ccd_plan_record(
114
+ {
115
+ "items": [{"content": "Beta", "status": "in_progress"}, {"content": "Gamma", "status": "pending"}],
116
+ "scope": "continuation",
117
+ }
118
+ ),
119
+ ccd_plan_record({"done": [1, 3], "start": 2}),
120
+ ],
121
+ )
122
+ assert plan_statuses(transcript_path) == [
123
+ ("Beta", "completed", False),
124
+ ("Gamma", "in_progress", False),
125
+ ("Alpha", "pending", True),
126
+ ]
127
+
128
+
129
+ def test_should_let_a_later_list_close_a_carried_item_by_subject(tmp_path: Path) -> None:
130
+ transcript_path = write_transcript(
131
+ tmp_path / "ccd.jsonl",
132
+ [
133
+ ccd_plan_record({"items": [{"content": "Alpha", "status": "pending"}], "scope": "first_task"}),
134
+ ccd_plan_record({"items": [{"content": "Beta", "status": "pending"}], "scope": "continuation"}),
135
+ ccd_plan_record(
136
+ {
137
+ "items": [{"content": "Beta", "status": "completed"}, {"content": "alpha", "status": "completed"}],
138
+ "scope": "continuation",
139
+ }
140
+ ),
141
+ ],
142
+ )
143
+ assert plan_statuses(transcript_path) == [("Beta", "completed", False), ("alpha", "completed", False)]
@@ -66,6 +66,14 @@ def test_unloaded_pull_request_command_is_denied(tmp_path: Path) -> None:
66
66
  assert "same command again" in decision["permissionDecisionReason"]
67
67
 
68
68
 
69
+ def test_executable_word_lists_reads_chains_prefixes_and_shell_wrappers() -> None:
70
+ assert gate.executable_word_lists("cd repo && env A=1 git commit -m x") == [
71
+ ["cd", "repo"],
72
+ ["git", "commit", "-m", "x"],
73
+ ]
74
+ assert gate.executable_word_lists("bash -c 'git add x; git commit'")[-2:] == [["git", "add", "x"], ["git", "commit"]]
75
+
76
+
69
77
  def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
70
78
  path = _transcript(tmp_path, "plugin:pr-lifecycle")
71
79
  assert _run_main(_payload("gh pr view", path)) == (0, "")