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
@@ -318,6 +318,23 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
318
318
  return { host, executable, status: 'installed', warning: null };
319
319
  }
320
320
 
321
+ /**
322
+ * Build the notice that tells the user to restart each session the install reached.
323
+ *
324
+ * A running Claude Code or Codex session keeps the plugin files it loaded at
325
+ * start, so a plugin installed or updated under it stays unavailable there
326
+ * until the session restarts.
327
+ *
328
+ * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins the install wrote.
329
+ * @param {ReadonlyArray<string>} allInstalledHosts The hosts the install reached.
330
+ * @returns {string} The restart notice.
331
+ */
332
+ export function restartNotice(allSpecs, allInstalledHosts) {
333
+ const pluginLabels = allSpecs.map(eachSpec => eachSpec.label).join(', ');
334
+ return `Restart each open ${allInstalledHosts.join(' and ')} session to load ${pluginLabels}. `
335
+ + 'A running session keeps the plugin version it loaded at start.';
336
+ }
337
+
321
338
  /**
322
339
  * Install a set of plugins from one marketplace into each host's own home.
323
340
  *
@@ -334,7 +351,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
334
351
  * `cmd.exe`'s command-not-found exit code on Windows, and both read as
335
352
  * skipped. When a host cannot replace a plugin folder because something holds
336
353
  * it, the warning names each process that holds it, so the user knows what to
337
- * close before running the install again.
354
+ * close before running the install again. When any host installs, the step
355
+ * prints one restart notice, because an open session keeps the plugin
356
+ * version it loaded at start.
338
357
  *
339
358
  * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
340
359
  * @param {object} [options] Install targets.
@@ -342,8 +361,8 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
342
361
  * @param {string} [options.codexHome] The Codex home to install into.
343
362
  * @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
344
363
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
345
- * @param {object} [dependencies] Seams for the command runner and the folder locker search.
346
- * @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
364
+ * @param {object} [dependencies] Seams for the command runner, the folder locker search, and the line writer.
365
+ * @returns {{status: string, hosts: object[], warning: string|null, restartNotice: string|null}} The outcome per host.
347
366
  */
348
367
  export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
349
368
  const environment = options.environment ?? process.env;
@@ -365,7 +384,15 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
365
384
  const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
366
385
  const status = failedCount > 0 ? 'failed' : (installedCount > 0 ? 'installed' : 'skipped');
367
386
  const warnings = hostOutcomes.map(outcome => outcome.warning).filter(Boolean);
368
- return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null };
387
+ const allInstalledHosts = hostOutcomes
388
+ .filter(outcome => outcome.status === 'installed')
389
+ .map(outcome => outcome.host);
390
+ const notice = allInstalledHosts.length > 0 ? restartNotice(allSpecs, allInstalledHosts) : null;
391
+ if (notice) {
392
+ const writeLine = dependencies.writeLine ?? (line => console.log(line));
393
+ writeLine(notice);
394
+ }
395
+ return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null, restartNotice: notice };
369
396
  }
370
397
 
371
398
  /**
@@ -17,6 +17,7 @@ import {
17
17
  removeOrphanedPluginVersions,
18
18
  removeStaleMarketplaceClone,
19
19
  removeRetiredMarketplacePlugins,
20
+ restartNotice,
20
21
  shouldInstallMarketplacePlugin,
21
22
  } from './install-pstack-plugin.mjs';
22
23
 
@@ -500,3 +501,25 @@ test('should delete a half-made marketplace clone before the shared marketplace
500
501
  test('should report no removal when no stale marketplace clone exists', () => {
501
502
  assert.equal(removeStaleMarketplaceClone(join(tmpdir(), 'cde-no-such-clone')), false);
502
503
  });
504
+
505
+ test('an install prints one restart notice that names each plugin and host', () => {
506
+ const allLoggedLines = [];
507
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
508
+ ...recordingRunner(),
509
+ writeLine: line => allLoggedLines.push(line),
510
+ });
511
+ assert.deepEqual(allLoggedLines, [restartNotice([PSTACK_PLUGIN_SPEC], ['claude', 'codex'])]);
512
+ assert.equal(outcome.restartNotice, allLoggedLines[0]);
513
+ assert.match(allLoggedLines[0], /Restart each open claude and codex session to load Pstack\./);
514
+ });
515
+
516
+ test('a run that installs nothing prints no restart notice', () => {
517
+ const allLoggedLines = [];
518
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
519
+ ...recordingRunner(() => ({ status: null, error: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) })),
520
+ writeLine: line => allLoggedLines.push(line),
521
+ });
522
+ assert.equal(outcome.status, 'skipped');
523
+ assert.deepEqual(allLoggedLines, []);
524
+ assert.equal(outcome.restartNotice, null);
525
+ });
@@ -35,11 +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, 4);
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\/open_task_ask_recorder\.py$/);
44
+ assert.match(userPromptHooks[5].command, /session\/plugin_change_warning\.py$/);
43
45
  assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
44
46
 
45
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: