claude-dev-env 8.96.8 → 8.98.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 (46) hide show
  1. package/bin/install-pstack-plugin.mjs +31 -4
  2. package/bin/install-pstack-plugin.test.mjs +23 -0
  3. package/bin/install.prompt-context.test.mjs +3 -1
  4. package/bin/install.settings-defaults.test.mjs +4 -3
  5. package/docs/agents-guidance/asks.md +6 -1
  6. package/hooks/blocking/open_tasks_stop_gate.py +128 -34
  7. package/hooks/blocking/open_tasks_transcript.py +112 -5
  8. package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
  9. package/hooks/blocking/python_windows_invocation_gate.py +249 -0
  10. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  11. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  12. package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
  13. package/hooks/blocking/test_open_tasks_transcript.py +89 -0
  14. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  15. package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
  16. package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
  17. package/hooks/blocking/verify_before_commit_gate.py +278 -0
  18. package/hooks/features/bash-dispatchers.md +3 -0
  19. package/hooks/features/conduct-gates.md +10 -2
  20. package/hooks/features/session-context.md +4 -0
  21. package/hooks/hooks.json +25 -0
  22. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  23. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
  24. package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
  25. package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
  26. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  27. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  28. package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
  29. package/hooks/open_task_asks.py +163 -0
  30. package/hooks/plugin_session_baseline.py +190 -0
  31. package/hooks/session/open_task_ask_recorder.py +38 -0
  32. package/hooks/session/plugin_change_warning.py +90 -0
  33. package/hooks/session/plugin_session_baseline_writer.py +53 -0
  34. package/hooks/session/test_open_task_ask_recorder.py +57 -0
  35. package/hooks/session/test_plugin_change_warning.py +216 -0
  36. package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
  37. package/hooks/test_context_budget_hooks.py +2 -0
  38. package/hooks/test_open_task_asks.py +52 -0
  39. package/hooks/test_plugin_session_baseline.py +98 -0
  40. package/hooks/test_transcript_skill_scan.py +24 -0
  41. package/hooks/transcript_skill_scan.py +54 -3
  42. package/package.json +1 -1
  43. package/scripts/policy_lint/adapter_configuration.py +5 -0
  44. package/scripts/policy_lint/config/constants.py +1 -0
  45. package/scripts/tests/test_adapter_configuration.py +8 -0
  46. package/settings.json +2 -1
@@ -91,9 +91,21 @@ def _command_segments(command: str) -> list[list[str]]:
91
91
  return [each_group for each_group in all_segments if each_group]
92
92
 
93
93
 
94
- def _next_command_word(
94
+ def next_command_word(
95
95
  all_words: list[str], start: int, all_options_with_value: frozenset[str]
96
96
  ) -> tuple[str | None, int]:
97
+ """Return the first word from ``start`` that is no option, and the index after it.
98
+
99
+ ::
100
+
101
+ ["git", "-C", "repo", "commit"], 1, {"-C"} -> ("commit", 4)
102
+ ["git", "--no-pager"], 1, {"-C"} -> (None, 2)
103
+
104
+ Args:
105
+ all_words: One command's words, executable first.
106
+ start: The index to start reading at.
107
+ all_options_with_value: Options whose next word is their value.
108
+ """
97
109
  index = start
98
110
  while index < len(all_words):
99
111
  each_word = all_words[index]
@@ -108,7 +120,7 @@ def _next_command_word(
108
120
 
109
121
 
110
122
  def _is_git_action(all_words: list[str]) -> bool:
111
- action, _ = _next_command_word(all_words, 1, GIT_GLOBAL_OPTIONS_WITH_VALUE)
123
+ action, _ = next_command_word(all_words, 1, GIT_GLOBAL_OPTIONS_WITH_VALUE)
112
124
  return action in GIT_ACTIONS
113
125
 
114
126
 
@@ -143,32 +155,62 @@ def _words_from_executable(all_words: list[str]) -> list[str]:
143
155
  return []
144
156
 
145
157
 
146
- def _file_name(path_word: str) -> str:
158
+ def file_name(path_word: str) -> str:
159
+ """Return the lowercased last path part of a word, such as ``git.exe`` for ``C:\\Git\\git.exe``.
160
+
161
+ Args:
162
+ path_word: A command word that may carry a path.
163
+ """
147
164
  return re.split(PATH_SEPARATOR_PATTERN, path_word)[-1].lower()
148
165
 
149
166
 
150
167
  def _runs_pull_request_script(all_words: list[str]) -> bool:
151
- script, _ = _next_command_word(all_words, 1, PYTHON_OPTIONS_WITH_VALUE)
152
- return script is not None and _file_name(script) == PULL_REQUEST_SCRIPT_NAME
168
+ script, _ = next_command_word(all_words, 1, PYTHON_OPTIONS_WITH_VALUE)
169
+ return script is not None and file_name(script) == PULL_REQUEST_SCRIPT_NAME
153
170
 
154
171
 
155
- def _matches_shell_command(command: str) -> bool:
172
+ def executable_word_lists(command: str) -> list[list[str]]:
173
+ """Return each command's words from its executable on, with shell wrapper arguments read as commands.
174
+
175
+ ::
176
+
177
+ "cd repo && git commit -m x" -> [["cd", "repo"], ["git", "commit", "-m", "x"]]
178
+ "env A=1 git push" -> [["git", "push"]]
179
+ "bash -c 'git add x; git commit'" -> ends with [["git", "add", "x"], ["git", "commit"]]
180
+
181
+ Args:
182
+ command: The shell command text.
183
+ """
184
+ all_word_lists: list[list[str]] = []
156
185
  for each_segment in _command_segments(command):
157
186
  all_command_words = _words_from_executable(each_segment)
158
187
  if not all_command_words:
159
188
  continue
160
- executable = _file_name(all_command_words[0])
189
+ all_word_lists.append(all_command_words)
190
+ all_word_lists.extend(_wrapped_word_lists(all_command_words))
191
+ return all_word_lists
192
+
193
+
194
+ def _wrapped_word_lists(all_command_words: list[str]) -> list[list[str]]:
195
+ if file_name(all_command_words[0]) not in ALL_SHELL_WRAPPER_EXECUTABLES:
196
+ return []
197
+ return [
198
+ each_word_list
199
+ for each_argument in all_command_words[1:]
200
+ for each_word_list in executable_word_lists(each_argument)
201
+ ]
202
+
203
+
204
+ def _matches_shell_command(command: str) -> bool:
205
+ for each_command_words in executable_word_lists(command):
206
+ executable = file_name(each_command_words[0])
161
207
  if executable == PULL_REQUEST_SCRIPT_NAME:
162
208
  return True
163
- if executable in ALL_PYTHON_EXECUTABLES and _runs_pull_request_script(all_command_words):
164
- return True
165
- if executable in ALL_SHELL_WRAPPER_EXECUTABLES and any(
166
- _matches_shell_command(each_argument) for each_argument in all_command_words[1:]
167
- ):
209
+ if executable in ALL_PYTHON_EXECUTABLES and _runs_pull_request_script(each_command_words):
168
210
  return True
169
- if executable in {"git", "git.exe"} and _is_git_action(all_command_words):
211
+ if executable in {"git", "git.exe"} and _is_git_action(each_command_words):
170
212
  return True
171
- if executable in {"gh", "gh.exe"} and _is_gh_action(all_command_words):
213
+ if executable in {"gh", "gh.exe"} and _is_gh_action(each_command_words):
172
214
  return True
173
215
  return False
174
216
 
@@ -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,204 @@ 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_record_nothing_for_a_question_only_prompt(
406
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
407
+ ) -> None:
408
+ assert record_ask("wait, did the export fail?") is None
409
+ assert record_ask("are you monitoring these? yes or no") is None
410
+ assert run_gate(monkeypatch, capsys, "No, it passed.") == (0, "")
411
+
412
+
413
+ def test_should_not_close_a_plan_item_on_a_done_line(
414
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
415
+ ) -> None:
416
+ transcript_path = write_records(
417
+ tmp_path / "s.jsonl",
418
+ [ccd_plan_record({"items": [{"content": "Port the hook", "status": "in_progress"}], "scope": "first_task"})],
419
+ )
420
+ assert "Port the hook" in block_reason(monkeypatch, capsys, "Done: Port the hook", transcript_path)
421
+
422
+
423
+ def test_should_close_an_ask_and_a_carried_item_on_a_dropped_line_quoting_the_user(
424
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
425
+ ) -> None:
426
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
427
+ transcript_path = submit_and_replace_list(tmp_path)
428
+ record_ask("Skip the APK export for now, set the rows instead")
429
+ reply_text = (
430
+ "Rows set.\nDone: #ask-2\n"
431
+ 'Dropped: #ask-1 - "skip the APK export for now"\n'
432
+ 'Dropped: Submit both APKs - "Skip the APK export"'
433
+ )
434
+ assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
435
+ with transcript_path.open("a", encoding="utf-8") as transcript_handle:
436
+ transcript_handle.write("\n" + json.dumps(assistant_text_record(reply_text)))
437
+ assert run_gate(monkeypatch, capsys, "Nothing else.", transcript_path) == (0, "")
438
+
439
+
440
+ def test_should_hold_a_dropped_line_whose_quote_the_user_never_wrote(
441
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
442
+ ) -> None:
443
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
444
+ transcript_path = submit_and_replace_list(tmp_path)
445
+ record_ask("Set the rows next")
446
+ reply_text = 'Dropped: #ask-1 - "the user cancelled the export"\nDropped: Submit both APKs - "skip it, it is done"'
447
+ reason_text = block_reason(monkeypatch, capsys, reply_text, transcript_path)
448
+ assert "#ask-1" in reason_text
449
+ assert "Submit both APKs" in reason_text
450
+
451
+
452
+ def test_should_hold_a_dropped_line_quoting_the_ask_itself(
453
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
454
+ ) -> None:
455
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
456
+ reason_text = block_reason(monkeypatch, capsys, 'Dropped: #ask-1 - "Export the OneUI8"')
457
+ assert "#ask-1" in reason_text
458
+
459
+
460
+ def test_should_hold_an_ask_again_when_the_next_reply_omits_its_open_line(
461
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
462
+ ) -> None:
463
+ record_ask("Export the OneUI8 and OneUI9 APKs and submit them")
464
+ first_reply = "Open: #ask-1 - the export job runs next"
465
+ assert run_gate(monkeypatch, capsys, first_reply) == (0, "")
466
+ transcript_path = write_records(tmp_path / "s.jsonl", [assistant_text_record(first_reply)])
467
+ assert "#ask-1" in block_reason(monkeypatch, capsys, "Still running.", transcript_path)
468
+
469
+
470
+ def test_should_allow_the_stop_once_a_carried_item_is_listed_completed(
471
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
472
+ ) -> None:
473
+ transcript_path = submit_and_replace_list(tmp_path)
474
+ assert "Submit both APKs" in block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
475
+ with transcript_path.open("a", encoding="utf-8") as transcript_handle:
476
+ relisted = ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "completed"}], "scope": "continuation"})
477
+ transcript_handle.write("\n" + json.dumps(relisted))
478
+ assert run_gate(monkeypatch, capsys, "Both submitted.", transcript_path) == (0, "")
479
+
480
+
481
+ def test_should_mark_the_right_item_for_a_done_index_after_a_continuation(
482
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
483
+ ) -> None:
484
+ replacing_items = [
485
+ {"content": "Read the schema", "status": "in_progress"},
486
+ {"content": "Set rows in Neon", "status": "pending"},
487
+ ]
488
+ transcript_path = write_records(
489
+ tmp_path / "s.jsonl",
490
+ [
491
+ ccd_plan_record({"items": [{"content": "Submit both APKs", "status": "pending"}], "scope": "first_task"}),
492
+ ccd_plan_record({"items": replacing_items, "scope": "continuation"}),
493
+ ccd_plan_record({"done": [1, 2]}),
494
+ ],
495
+ )
496
+ reason_text = block_reason(monkeypatch, capsys, "Rows set.", transcript_path)
497
+ assert "Submit both APKs" in reason_text
498
+ assert "Read the schema" not in reason_text
499
+ assert "Set rows in Neon" not in reason_text