claude-dev-env 8.97.0 → 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 (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 +128 -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 +202 -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
@@ -35,12 +35,13 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
35
35
  mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
36
36
 
37
37
  const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
38
- assert.equal(userPromptHooks.length, 5);
38
+ assert.equal(userPromptHooks.length, 6);
39
39
  assert.equal(userPromptHooks[0].command, userCommand);
40
40
  assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
41
41
  assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
42
42
  assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
43
- assert.match(userPromptHooks[4].command, /session\/plugin_change_warning\.py$/);
43
+ assert.match(userPromptHooks[4].command, /session\/open_task_ask_recorder\.py$/);
44
+ assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
44
45
  assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
45
46
 
46
47
  const firstMerge = JSON.stringify(settings);
@@ -268,7 +268,7 @@ test('sandbox uninstall removes only package-owned permission entries and keeps
268
268
  }
269
269
  });
270
270
 
271
- test('package settings.json publishes the session agent, advisor model, auto-compact, auto mode and idle compaction defaults', () => {
271
+ test('package settings.json publishes the session agent, advisor model, auto-compact, auto mode, idle compaction and Python UTF-8 defaults', () => {
272
272
  const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
273
273
  const settingsDefaults = settingsDefaultsFromPackageSettings(packageSettings);
274
274
  assert.deepEqual(
@@ -276,7 +276,7 @@ test('package settings.json publishes the session agent, advisor model, auto-com
276
276
  ['advisorModel', 'agent', 'autoCompactWindow', 'autoMode', 'env', 'modelSettings'],
277
277
  );
278
278
  assert.equal(settingsDefaults.autoCompactWindow, 350000);
279
- assert.deepEqual(settingsDefaults.env, { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000' });
279
+ assert.deepEqual(settingsDefaults.env, { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000', PYTHONUTF8: '1' });
280
280
  assert.deepEqual(settingsDefaults.modelSettings, {
281
281
  'claude-haiku-5-5': { autoCompactWindow: 100000 },
282
282
  'claude-haiku-4-5': { autoCompactWindow: 100000 },
@@ -307,7 +307,7 @@ test('a missing default is added and a user-set value is kept', () => {
307
307
  assert.equal(userSettings.advisorModel, 'sonnet');
308
308
  });
309
309
 
310
- test('install adds the idle compaction variable to an env block and keeps a user value on rerun', () => {
310
+ test('install adds the idle compaction and Python UTF-8 variables to an env block and keeps a user value on rerun', () => {
311
311
  const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
312
312
  try {
313
313
  const settingsPath = join(sandboxHome, '.claude', 'settings.json');
@@ -318,6 +318,7 @@ test('install adds the idle compaction variable to an env block and keeps a user
318
318
  assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).env, {
319
319
  EDITOR: 'vim',
320
320
  CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000',
321
+ PYTHONUTF8: '1',
321
322
  });
322
323
  const userChoice = JSON.parse(readFileSync(settingsPath, 'utf8'));
323
324
  userChoice.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS = '300000';
@@ -29,7 +29,12 @@ The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
29
29
  - Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
30
30
  - Cursor: a native `stop` entry in `~/.cursor/hooks.json`. The reason comes back as `followup_message`, the next user message. A stop with status `aborted` or `error` passes, so a turn the user cancels stays silent.
31
31
 
32
- The gate reads the task list from the task store, the transcript TaskCreate, TaskUpdate, and TodoWrite calls, or the Codex `update_plan` calls. It ends the hold when each open item is named on a `Blocked:` or `Open:` line of the closing reply.
32
+ The gate merges the task store, the transcript TaskCreate, TaskUpdate, and TodoWrite calls, the Codex `update_plan` calls, and the Claude Code desktop `mcp__ccd_turn__update_plan` calls. Each new desktop plan list keeps the unfinished items of the list before it. In Claude Code, `open_task_ask_recorder.py` also records each prompt that asks for work as `#ask-N`. The gate ends the hold when each open item is named on a `Blocked:` or `Open:` line of the closing reply, and that line releases only the current stop.
33
+
34
+ Close an item for good with one of these lines:
35
+
36
+ - `Done: #ask-N` for a finished ask. A plan item closes only when the plan marks it completed.
37
+ - `Dropped: <#ask-N or carried item> - "<the user's words>"` for an ask or carried plan item the user called off. The quote must come from a prompt the user sent after that ask or after the plan list that held the item.
33
38
 
34
39
  ## Cursor
35
40
 
@@ -1,20 +1,34 @@
1
1
  #!/usr/bin/env python3
2
2
  """Stop hook that keeps a session working while its task list has unreported open items.
3
3
 
4
- The session's task list comes from the first of these that exists::
4
+ The session's open items merge these sources, one item per subject::
5
5
 
6
- ~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
7
- transcript TaskCreate / TaskUpdate calls (replayed in order)
8
- transcript TodoWrite input.todos (last call wins)
9
- transcript Codex update_plan arguments (last call wins)
6
+ ~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
7
+ transcript TaskCreate / TaskUpdate calls (replayed in order)
8
+ transcript TodoWrite input.todos (last call wins)
9
+ transcript Codex update_plan arguments (last call wins)
10
+ transcript mcp__ccd_turn__update_plan calls (unfinished items carry into a replacing list)
11
+ ~/.claude/open-task-asks/<session id>.json (each prompt that asks for work, as #ask-N)
10
12
 
11
- An item with status ``pending`` or ``in_progress`` is open. The turn may end
12
- only when the closing reply names every open item on a report line::
13
+ An item with status ``pending`` or ``in_progress`` is open, and so is an ask
14
+ until a reply closes it. The turn may end only when the closing reply names
15
+ every open item on a report line::
13
16
 
14
17
  allow: Blocked: Rotate the deploy key - the owner must approve the org secret
15
18
  allow: - Open: #3 - waits on the release checks
19
+ allow: Open: #ask-2 - the export job runs next
16
20
  block: (reply never mentions "Rotate the deploy key")
17
21
 
22
+ A report line releases only the current stop. Two lines in any assistant text
23
+ of the session close an item for good::
24
+
25
+ Done: #ask-2 closes an ask; never a plan item
26
+ Dropped: #ask-1 - "skip the APK export" closes an ask
27
+ Dropped: Submit both APKs - "skip the APK export" closes a carried plan item
28
+
29
+ A ``Dropped:`` quote must appear in a prompt the user sent after that ask, or
30
+ after the last plan list that held the item.
31
+
18
32
  A block sends the open items back as the reason, so the model keeps working or
19
33
  reports them. Claude Code ends the turn after eight blocks in a row. Codex
20
34
  turns the reason into a continuation prompt. A Cursor payload (it carries
@@ -45,14 +59,19 @@ if blocking_directory not in sys.path:
45
59
 
46
60
  from hooks_constants.hook_block_logger import log_hook_block
47
61
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
48
- from open_tasks_transcript import TaskItem, parsed_object, replayed_transcript
62
+ from open_task_asks import RecordedPrompt, claude_home, recorded_prompts
63
+ from open_tasks_transcript import TaskItem, normalized_text, parsed_object, replayed_transcript
49
64
  from hooks_constants.open_tasks_stop_gate_constants import (
50
65
  ALLOW_EXIT_CODE,
66
+ ASK_CLOSE_SUFFIX,
67
+ ASK_ID_PREFIX,
68
+ ASK_LINE_TEMPLATE,
69
+ ASK_REFERENCE_PATTERN,
70
+ ASK_SUBJECT_CHARACTER_LIMIT,
71
+ ASK_SUBJECT_ELLIPSIS,
51
72
  BLOCK_DECISION,
52
73
  BLOCK_REASON_PREFIX,
53
74
  BLOCK_REASON_SUFFIX,
54
- CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
55
- CLAUDE_HOME_DIRECTORY_NAME,
56
75
  CURSOR_COMPLETED_STATUS,
57
76
  CURSOR_CONVERSATION_ID_KEY,
58
77
  CURSOR_FOLLOWUP_LIMIT,
@@ -60,10 +79,16 @@ from hooks_constants.open_tasks_stop_gate_constants import (
60
79
  CURSOR_LOOP_COUNT_KEY,
61
80
  CURSOR_STATUS_KEY,
62
81
  DECISION_KEY,
82
+ DONE_LINE_PATTERN,
83
+ DONE_TEXT_GROUP_INDEX,
84
+ DROPPED_LINE_PATTERN,
85
+ DROPPED_QUOTE_GROUP_INDEX,
86
+ DROPPED_TARGET_GROUP_INDEX,
63
87
  HOOK_EVENT_NAME,
64
88
  INCOMPLETE_STATUSES,
65
89
  ITEM_LINE_TEMPLATE,
66
90
  LAST_ASSISTANT_MESSAGE_KEY,
91
+ PENDING_STATUS,
67
92
  REASON_KEY,
68
93
  REPORT_LINE_PATTERN,
69
94
  REPORTED_TEXT_GROUP_INDEX,
@@ -78,17 +103,9 @@ from hooks_constants.open_tasks_stop_gate_constants import (
78
103
  TRANSCRIPT_PATH_KEY,
79
104
  UTF8_ENCODING,
80
105
  WORD_BOUNDARY_PATTERN,
81
- WORD_SEPARATOR,
82
106
  )
83
107
 
84
108
 
85
- def _claude_home() -> Path:
86
- configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
87
- if configured_directory:
88
- return Path(configured_directory)
89
- return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
90
-
91
-
92
109
  def _task_file_item(task_file: Path) -> TaskItem | None:
93
110
  try:
94
111
  task_text = task_file.read_text(encoding=UTF8_ENCODING)
@@ -109,25 +126,30 @@ def task_store_items(session_id: object) -> list[TaskItem] | None:
109
126
  task_list_id = os.environ.get(TASK_LIST_ID_ENVIRONMENT_VARIABLE) or session_id
110
127
  if not isinstance(task_list_id, str) or not task_list_id:
111
128
  return None
112
- task_directory = _claude_home() / TASKS_DIRECTORY_NAME / task_list_id
129
+ task_directory = claude_home() / TASKS_DIRECTORY_NAME / task_list_id
113
130
  if not task_directory.is_dir():
114
131
  return None
115
132
  all_items = (_task_file_item(each_file) for each_file in sorted(task_directory.glob(TASK_FILE_GLOB)))
116
133
  return [each_item for each_item in all_items if each_item is not None]
117
134
 
118
135
 
119
- def _normalized(text: str) -> str:
120
- return WORD_SEPARATOR.join(text.lower().split())
136
+ def _line_matches(line_pattern: str, all_texts: list[str]) -> list[re.Match[str]]:
137
+ all_matches = (
138
+ re.match(line_pattern, each_line, re.IGNORECASE) for each_text in all_texts for each_line in each_text.splitlines()
139
+ )
140
+ return [each_match for each_match in all_matches if each_match]
121
141
 
122
142
 
123
143
  def _reported_texts(reply_text: str) -> list[str]:
124
- all_matches = (re.match(REPORT_LINE_PATTERN, each_line, re.IGNORECASE) for each_line in reply_text.splitlines())
125
- return [_normalized(each_match.group(REPORTED_TEXT_GROUP_INDEX)) for each_match in all_matches if each_match]
144
+ return [
145
+ normalized_text(each_match.group(REPORTED_TEXT_GROUP_INDEX))
146
+ for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
147
+ ]
126
148
 
127
149
 
128
150
  def is_reported(task_item: TaskItem, reply_text: str) -> bool:
129
151
  """Return True when a Blocked: or Open: line of the reply names the item."""
130
- subject_text = _normalized(task_item.subject)
152
+ subject_text = normalized_text(task_item.subject)
131
153
  number_pattern = re.escape(TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)) + WORD_BOUNDARY_PATTERN
132
154
  return any(
133
155
  (subject_text and subject_text in each_text) or re.search(number_pattern, each_text)
@@ -135,14 +157,82 @@ def is_reported(task_item: TaskItem, reply_text: str) -> bool:
135
157
  )
136
158
 
137
159
 
160
+ def _is_quoted_after(quote_text: str, all_later_prompts: list[RecordedPrompt]) -> bool:
161
+ normalized_quote = normalized_text(quote_text)
162
+ return bool(normalized_quote) and any(
163
+ normalized_quote in normalized_text(each_prompt.text) for each_prompt in all_later_prompts
164
+ )
165
+
166
+
167
+ def _ask_subject(prompt_text: str) -> str:
168
+ first_line = prompt_text.strip().splitlines()[0]
169
+ if len(first_line) <= ASK_SUBJECT_CHARACTER_LIMIT:
170
+ return first_line
171
+ return first_line[:ASK_SUBJECT_CHARACTER_LIMIT] + ASK_SUBJECT_ELLIPSIS
172
+
173
+
174
+ def open_ask_items(all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> list[TaskItem]:
175
+ """Return each recorded ask that no ``Done:`` line and no quoted ``Dropped:`` line closes."""
176
+ all_done_ids = {
177
+ each_reference
178
+ for each_match in _line_matches(DONE_LINE_PATTERN, all_closing_texts)
179
+ for each_reference in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DONE_TEXT_GROUP_INDEX))
180
+ }
181
+ all_drop_matches = _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
182
+ all_open_asks = []
183
+ for each_position, each_prompt in enumerate(all_prompts):
184
+ if not each_prompt.ask_id or each_prompt.ask_id in all_done_ids:
185
+ continue
186
+ is_dropped = any(
187
+ each_prompt.ask_id in re.findall(ASK_REFERENCE_PATTERN, each_match.group(DROPPED_TARGET_GROUP_INDEX))
188
+ and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_prompts[each_position + 1 :])
189
+ for each_match in all_drop_matches
190
+ )
191
+ if not is_dropped:
192
+ all_open_asks.append(TaskItem(each_prompt.ask_id, _ask_subject(each_prompt.text), PENDING_STATUS))
193
+ return all_open_asks
194
+
195
+
196
+ def is_dropped_carried_item(task_item: TaskItem, all_prompts: list[RecordedPrompt], all_closing_texts: list[str]) -> bool:
197
+ """Return True when a ``Dropped:`` line names the carried item and quotes a prompt sent after its list."""
198
+ listed_at = task_item.listed_at
199
+ subject_text = normalized_text(task_item.subject)
200
+ if not task_item.is_carried or listed_at is None or not subject_text:
201
+ return False
202
+ all_later_prompts = [each_prompt for each_prompt in all_prompts if each_prompt.recorded_at > listed_at]
203
+ return any(
204
+ subject_text in normalized_text(each_match.group(DROPPED_TARGET_GROUP_INDEX))
205
+ and _is_quoted_after(each_match.group(DROPPED_QUOTE_GROUP_INDEX), all_later_prompts)
206
+ for each_match in _line_matches(DROPPED_LINE_PATTERN, all_closing_texts)
207
+ )
208
+
209
+
210
+ def deduplicated_by_subject(all_items: list[TaskItem]) -> list[TaskItem]:
211
+ """Keep the first item for each subject, so the task store wins over the transcript."""
212
+ item_by_key: dict[str, TaskItem] = {}
213
+ for each_item in all_items:
214
+ item_key = normalized_text(each_item.subject) or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=each_item.item_id)
215
+ item_by_key.setdefault(item_key, each_item)
216
+ return list(item_by_key.values())
217
+
218
+
138
219
  def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[TaskItem]:
139
- """Return the open task items the closing reply does not report."""
220
+ """Return the open task items and asks the closing reply does not report."""
140
221
  replay = replayed_transcript(all_hook_input_fields.get(TRANSCRIPT_PATH_KEY))
141
- all_items = task_store_items(all_hook_input_fields.get(SESSION_ID_KEY))
142
- if all_items is None:
143
- all_items = replay.items() or []
222
+ session_id = all_hook_input_fields.get(SESSION_ID_KEY)
144
223
  reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
145
224
  reply_text = reply_value if isinstance(reply_value, str) else replay.last_assistant_text
225
+ all_closing_texts = [*replay.all_assistant_texts, reply_text]
226
+ all_prompts = recorded_prompts(session_id)
227
+ all_replayed_items = [
228
+ each_item
229
+ for each_item in replay.items() or []
230
+ if not is_dropped_carried_item(each_item, all_prompts, all_closing_texts)
231
+ ]
232
+ all_items = [
233
+ *deduplicated_by_subject([*(task_store_items(session_id) or []), *all_replayed_items]),
234
+ *open_ask_items(all_prompts, all_closing_texts),
235
+ ]
146
236
  return [
147
237
  each_item
148
238
  for each_item in all_items
@@ -150,13 +240,17 @@ def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[Task
150
240
  ]
151
241
 
152
242
 
243
+ def _item_line(task_item: TaskItem) -> str:
244
+ if task_item.item_id.startswith(ASK_ID_PREFIX):
245
+ return ASK_LINE_TEMPLATE.format(ask_id=task_item.item_id, subject=task_item.subject)
246
+ return ITEM_LINE_TEMPLATE.format(subject=task_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id))
247
+
248
+
153
249
  def block_reason_for(all_open_items: list[TaskItem]) -> str:
154
- """Return the block reason that lists each open item."""
155
- all_item_lines = (
156
- ITEM_LINE_TEMPLATE.format(subject=each_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=each_item.item_id))
157
- for each_item in all_open_items
158
- )
159
- return BLOCK_REASON_PREFIX + "".join(all_item_lines) + BLOCK_REASON_SUFFIX
250
+ """Return the block reason that lists each open item, and how to close an ask when one is open."""
251
+ has_open_ask = any(each_item.item_id.startswith(ASK_ID_PREFIX) for each_item in all_open_items)
252
+ all_item_lines = "".join(_item_line(each_item) for each_item in all_open_items)
253
+ return BLOCK_REASON_PREFIX + all_item_lines + BLOCK_REASON_SUFFIX + (ASK_CLOSE_SUFFIX if has_open_ask else "")
160
254
 
161
255
 
162
256
  def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env python3
2
2
  """Rebuild a session task list and its last assistant text from a transcript.
3
3
 
4
- The open tasks Stop gate reads three transcript shapes, replayed in order::
4
+ The open tasks Stop gate reads four transcript shapes, replayed in order::
5
5
 
6
6
  TaskCreate / TaskUpdate calls (Claude Code)
7
7
  TodoWrite input.todos (Claude Code and Cursor; ``merge`` updates by id)
8
8
  update_plan function_call arguments (Codex rollout)
9
+ mcp__ccd_turn__update_plan input (Claude Code desktop plan)
10
+
11
+ A desktop plan call either lists items or moves the current list::
12
+
13
+ {"items": [...], "scope": "continuation"} -> new list, unfinished old items carried after it
14
+ {"done": [2], "start": 3} -> 1-based positions in the tool's own items
15
+ {"advance": true} -> complete the in_progress item, start the next pending
16
+
17
+ Only ``assistant`` records count for a desktop plan call, because Claude Code
18
+ also copies each call into ``api-request-blob`` records.
9
19
 
10
20
  The Cursor CLI can also reach TodoWrite through ``CallDynamicTool``, with the
11
21
  TodoWrite input under ``arguments``; that call counts as the TodoWrite call.
@@ -16,6 +26,7 @@ from __future__ import annotations
16
26
  import json
17
27
  import re
18
28
  import sys
29
+ from datetime import datetime
19
30
  from pathlib import Path
20
31
  from typing import NamedTuple
21
32
 
@@ -24,7 +35,15 @@ if hooks_root_directory not in sys.path:
24
35
  sys.path.insert(0, hooks_root_directory)
25
36
 
26
37
  from hooks_constants.open_tasks_stop_gate_constants import (
38
+ ASSISTANT_RECORD_TYPE,
27
39
  ASSISTANT_ROLE,
40
+ CCD_PLAN_ADVANCE_KEY,
41
+ CCD_PLAN_CONTENT_KEY,
42
+ CCD_PLAN_DONE_KEY,
43
+ CCD_PLAN_ITEM_ID_TEMPLATE,
44
+ CCD_PLAN_ITEMS_KEY,
45
+ CCD_PLAN_START_KEY,
46
+ CCD_PLAN_TOOL_NAME,
28
47
  CODEX_ARGUMENTS_KEY,
29
48
  CODEX_FUNCTION_CALL_TYPE,
30
49
  CODEX_PAYLOAD_KEY,
@@ -32,14 +51,18 @@ from hooks_constants.open_tasks_stop_gate_constants import (
32
51
  CODEX_PLAN_TOOL_NAME,
33
52
  CODEX_RESPONSE_ITEM_TYPE,
34
53
  CODEX_STEP_KEY,
54
+ COMPLETED_STATUS,
35
55
  CONTENT_KEY,
36
56
  CREATED_TASK_ID_GROUP_INDEX,
37
57
  DYNAMIC_TOOL_ARGUMENTS_KEY,
38
58
  DYNAMIC_TOOL_CALL_NAME,
39
59
  DYNAMIC_TOOL_NAME_KEY,
40
60
  FIRST_ITEM_NUMBER,
61
+ INCOMPLETE_STATUSES,
62
+ IN_PROGRESS_STATUS,
41
63
  MESSAGE_KEY,
42
64
  PENDING_STATUS,
65
+ RECORD_TIMESTAMP_KEY,
43
66
  RECORD_TYPE_KEY,
44
67
  REPLY_TEXT_SEPARATOR,
45
68
  ROLE_KEY,
@@ -61,6 +84,8 @@ from hooks_constants.open_tasks_stop_gate_constants import (
61
84
  TOOL_RESULT_BLOCK_TYPE,
62
85
  TOOL_USE_BLOCK_TYPE,
63
86
  TOOL_USE_ID_KEY,
87
+ UTC_OFFSET_TEXT,
88
+ UTC_SUFFIX,
64
89
  UTF8_ENCODING,
65
90
  WORD_SEPARATOR,
66
91
  )
@@ -73,11 +98,31 @@ class TaskItem(NamedTuple):
73
98
  item_id: The task number or position, as text.
74
99
  subject: The item's wording.
75
100
  status: The item's status, such as ``pending`` or ``completed``.
101
+ listed_at: For a desktop plan item, seconds since the epoch of the last list that held it.
102
+ is_carried: True for a desktop plan item a replacing list left out.
76
103
  """
77
104
 
78
105
  item_id: str
79
106
  subject: str
80
107
  status: str
108
+ listed_at: float | None = None
109
+ is_carried: bool = False
110
+
111
+
112
+ def normalized_text(text: str) -> str:
113
+ """Return the text lowercased with each run of whitespace collapsed to one space."""
114
+ return WORD_SEPARATOR.join(text.lower().split())
115
+
116
+
117
+ def record_epoch(all_record_fields: dict[str, object]) -> float | None:
118
+ """Return the record's ISO ``timestamp`` as seconds since the epoch, or None."""
119
+ timestamp_value = all_record_fields.get(RECORD_TIMESTAMP_KEY)
120
+ if not isinstance(timestamp_value, str):
121
+ return None
122
+ try:
123
+ return datetime.fromisoformat(timestamp_value.replace(UTC_SUFFIX, UTC_OFFSET_TEXT)).timestamp()
124
+ except ValueError:
125
+ return None
81
126
 
82
127
 
83
128
  def parsed_object(json_text: str) -> dict[str, object] | None:
@@ -171,6 +216,22 @@ def codex_plan_items(all_record_fields: dict[str, object]) -> list[TaskItem] | N
171
216
  return _numbered_items(all_plan_arguments.get(CODEX_PLAN_KEY), CODEX_STEP_KEY)
172
217
 
173
218
 
219
+ def advanced_items(all_own_items: list[TaskItem]) -> list[TaskItem]:
220
+ """Complete the in_progress item and start the next pending one after it, as ``advance`` does."""
221
+ all_advanced_items = list(all_own_items)
222
+ search_start = 0
223
+ for each_position, each_item in enumerate(all_advanced_items):
224
+ if each_item.status == IN_PROGRESS_STATUS:
225
+ all_advanced_items[each_position] = each_item._replace(status=COMPLETED_STATUS)
226
+ search_start = each_position + 1
227
+ break
228
+ for each_position in range(search_start, len(all_advanced_items)):
229
+ if all_advanced_items[each_position].status == PENDING_STATUS:
230
+ all_advanced_items[each_position] = all_advanced_items[each_position]._replace(status=IN_PROGRESS_STATUS)
231
+ break
232
+ return all_advanced_items
233
+
234
+
174
235
  def unwrapped_tool_call(tool_name: str, all_input_fields: dict[str, object]) -> tuple[str, dict[str, object]]:
175
236
  """Return the inner tool name and arguments of a Cursor ``CallDynamicTool`` call, or the call itself.
176
237
 
@@ -191,7 +252,10 @@ class _TranscriptReplay:
191
252
  self.latest_items: list[TaskItem] | None = None
192
253
  self.created_by_id: dict[str, TaskItem] = {}
193
254
  self.subject_by_tool_use_id: dict[str, str] = {}
255
+ self.plan_items: list[TaskItem] | None = None
256
+ self.own_plan_count = 0
194
257
  self.last_assistant_text = ""
258
+ self.all_assistant_texts: list[str] = []
195
259
 
196
260
  def apply_record(self, all_record_fields: dict[str, object]) -> None:
197
261
  plan_items = codex_plan_items(all_record_fields)
@@ -199,10 +263,46 @@ class _TranscriptReplay:
199
263
  self.latest_items = plan_items
200
264
  return
201
265
  all_blocks = _content_blocks(all_record_fields)
266
+ is_assistant_record = all_record_fields.get(RECORD_TYPE_KEY) == ASSISTANT_RECORD_TYPE
202
267
  for each_block in all_blocks:
203
- self.apply_block(each_block)
268
+ plan_input = each_block.get(TOOL_INPUT_KEY)
269
+ if is_assistant_record and each_block.get(TOOL_NAME_KEY) == CCD_PLAN_TOOL_NAME and isinstance(plan_input, dict):
270
+ self.apply_desktop_plan(plan_input, record_epoch(all_record_fields))
271
+ else:
272
+ self.apply_block(each_block)
204
273
  self.note_assistant_text(all_record_fields, all_blocks)
205
274
 
275
+ def apply_desktop_plan(self, all_plan_fields: dict[str, object], listed_at: float | None) -> None:
276
+ all_listed_entries = all_plan_fields.get(CCD_PLAN_ITEMS_KEY)
277
+ if isinstance(all_listed_entries, list):
278
+ self.replace_desktop_plan(all_listed_entries, listed_at)
279
+ return
280
+ all_own_items = (self.plan_items or [])[: self.own_plan_count]
281
+ all_done_positions = all_plan_fields.get(CCD_PLAN_DONE_KEY)
282
+ for each_position in all_done_positions if isinstance(all_done_positions, list) else []:
283
+ if isinstance(each_position, int) and FIRST_ITEM_NUMBER <= each_position <= len(all_own_items):
284
+ all_own_items[each_position - FIRST_ITEM_NUMBER] = all_own_items[each_position - FIRST_ITEM_NUMBER]._replace(status=COMPLETED_STATUS)
285
+ start_position = all_plan_fields.get(CCD_PLAN_START_KEY)
286
+ if isinstance(start_position, int) and FIRST_ITEM_NUMBER <= start_position <= len(all_own_items):
287
+ all_own_items[start_position - FIRST_ITEM_NUMBER] = all_own_items[start_position - FIRST_ITEM_NUMBER]._replace(status=IN_PROGRESS_STATUS)
288
+ if all_plan_fields.get(CCD_PLAN_ADVANCE_KEY) is True:
289
+ all_own_items = advanced_items(all_own_items)
290
+ self.plan_items = all_own_items + (self.plan_items or [])[self.own_plan_count :]
291
+
292
+ def replace_desktop_plan(self, all_listed_entries: list[object], listed_at: float | None) -> None:
293
+ all_own_items = [
294
+ each_item._replace(item_id=CCD_PLAN_ITEM_ID_TEMPLATE.format(position=each_item.item_id), listed_at=listed_at)
295
+ for each_item in _numbered_items(all_listed_entries, CCD_PLAN_CONTENT_KEY)
296
+ ]
297
+ all_listed_subjects = {normalized_text(each_item.subject) for each_item in all_own_items}
298
+ all_carried_items = [
299
+ each_item._replace(is_carried=True)
300
+ for each_item in self.plan_items or []
301
+ if each_item.status in INCOMPLETE_STATUSES and normalized_text(each_item.subject) not in all_listed_subjects
302
+ ]
303
+ self.plan_items = all_own_items + all_carried_items
304
+ self.own_plan_count = len(all_own_items)
305
+
206
306
  def note_assistant_text(self, all_record_fields: dict[str, object], all_blocks: list[dict[str, object]]) -> None:
207
307
  message_body = all_record_fields.get(MESSAGE_KEY)
208
308
  message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
@@ -213,6 +313,7 @@ class _TranscriptReplay:
213
313
  ).strip()
214
314
  if record_text:
215
315
  self.last_assistant_text = record_text
316
+ self.all_assistant_texts.append(record_text)
216
317
 
217
318
  def apply_block(self, all_block_fields: dict[str, object]) -> None:
218
319
  block_type = all_block_fields.get(RECORD_TYPE_KEY)
@@ -252,9 +353,15 @@ class _TranscriptReplay:
252
353
  self.created_by_id[created_id] = TaskItem(created_id, created_subject, PENDING_STATUS)
253
354
 
254
355
  def items(self) -> list[TaskItem] | None:
255
- if self.created_by_id:
256
- return list(self.created_by_id.values())
257
- return self.latest_items
356
+ all_sources = [
357
+ list(self.created_by_id.values()) if self.created_by_id else None,
358
+ self.latest_items,
359
+ self.plan_items,
360
+ ]
361
+ all_present_sources = [each_source for each_source in all_sources if each_source is not None]
362
+ if not all_present_sources:
363
+ return None
364
+ return [each_item for each_source in all_present_sources for each_item in each_source]
258
365
 
259
366
 
260
367
  def replayed_transcript(transcript_path: object) -> _TranscriptReplay:
@@ -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