claude-dev-env 8.98.0 → 8.98.2
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.
- package/hooks/blocking/open_tasks_stop_gate.py +19 -9
- package/hooks/blocking/pr_lifecycle_skill_gate.py +1 -1
- package/hooks/blocking/test_open_tasks_stop_gate.py +13 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +4 -0
- package/hooks/features/conduct-gates.md +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
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 open items merge these sources
|
|
4
|
+
The session's open items merge these sources. A transcript item whose subject
|
|
5
|
+
the task store holds is dropped, so the task store wins; every other item stays::
|
|
5
6
|
|
|
6
7
|
~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
|
|
7
8
|
transcript TaskCreate / TaskUpdate calls (replayed in order)
|
|
@@ -207,13 +208,22 @@ def is_dropped_carried_item(task_item: TaskItem, all_prompts: list[RecordedPromp
|
|
|
207
208
|
)
|
|
208
209
|
|
|
209
210
|
|
|
210
|
-
def
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
def _subject_key(task_item: TaskItem) -> str:
|
|
212
|
+
return normalized_text(task_item.subject) or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def deduplicated_by_subject(all_sources: list[list[TaskItem]]) -> list[TaskItem]:
|
|
216
|
+
"""Drop an item whose subject an earlier source holds, so the task store wins over the transcript.
|
|
217
|
+
|
|
218
|
+
The caller passes the task store and the whole transcript as two sources. Items inside one source all
|
|
219
|
+
stay, so a pending task keeps its place beside a completed task of the same subject.
|
|
220
|
+
"""
|
|
221
|
+
all_kept_items: list[TaskItem] = []
|
|
222
|
+
all_earlier_keys: set[str] = set()
|
|
223
|
+
for each_source in all_sources:
|
|
224
|
+
all_kept_items.extend(each_item for each_item in each_source if _subject_key(each_item) not in all_earlier_keys)
|
|
225
|
+
all_earlier_keys.update(_subject_key(each_item) for each_item in each_source)
|
|
226
|
+
return all_kept_items
|
|
217
227
|
|
|
218
228
|
|
|
219
229
|
def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[TaskItem]:
|
|
@@ -230,7 +240,7 @@ def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[Task
|
|
|
230
240
|
if not is_dropped_carried_item(each_item, all_prompts, all_closing_texts)
|
|
231
241
|
]
|
|
232
242
|
all_items = [
|
|
233
|
-
*deduplicated_by_subject([
|
|
243
|
+
*deduplicated_by_subject([task_store_items(session_id) or [], all_replayed_items]),
|
|
234
244
|
*open_ask_items(all_prompts, all_closing_texts),
|
|
235
245
|
]
|
|
236
246
|
return [
|
|
@@ -81,7 +81,7 @@ def _command_segments(command: str) -> list[list[str]]:
|
|
|
81
81
|
except ValueError:
|
|
82
82
|
return []
|
|
83
83
|
for each_word in all_words:
|
|
84
|
-
if each_word[0] in COMMAND_SEPARATORS:
|
|
84
|
+
if each_word and each_word[0] in COMMAND_SEPARATORS:
|
|
85
85
|
all_segments.append(each_segment)
|
|
86
86
|
each_segment = []
|
|
87
87
|
continue
|
|
@@ -402,6 +402,19 @@ def test_should_hold_an_ask_whose_subject_matches_a_completed_task(
|
|
|
402
402
|
assert "#ask-1: Fix login" in reason_text
|
|
403
403
|
|
|
404
404
|
|
|
405
|
+
def test_should_hold_a_pending_task_that_shares_a_subject_with_a_completed_task(
|
|
406
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
407
|
+
) -> None:
|
|
408
|
+
write_task_store(
|
|
409
|
+
tmp_path,
|
|
410
|
+
[
|
|
411
|
+
{"id": "1", "subject": "Run tests", "status": "completed"},
|
|
412
|
+
{"id": "2", "subject": "Run tests", "status": "pending"},
|
|
413
|
+
],
|
|
414
|
+
)
|
|
415
|
+
assert "Run tests" in block_reason(monkeypatch, capsys, "Done.")
|
|
416
|
+
|
|
417
|
+
|
|
405
418
|
def test_should_record_nothing_for_a_question_only_prompt(
|
|
406
419
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
407
420
|
) -> None:
|
|
@@ -74,6 +74,10 @@ def test_executable_word_lists_reads_chains_prefixes_and_shell_wrappers() -> Non
|
|
|
74
74
|
assert gate.executable_word_lists("bash -c 'git add x; git commit'")[-2:] == [["git", "add", "x"], ["git", "commit"]]
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def test_executable_word_lists_keeps_an_empty_quoted_argument() -> None:
|
|
78
|
+
assert gate.executable_word_lists('git commit -m ""') == [["git", "commit", "-m", ""]]
|
|
79
|
+
|
|
80
|
+
|
|
77
81
|
def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
|
|
78
82
|
path = _transcript(tmp_path, "plugin:pr-lifecycle")
|
|
79
83
|
assert _run_main(_payload("gh pr view", path)) == (0, "")
|
|
@@ -6,7 +6,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
6
6
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
8
|
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
|
|
9
|
-
- `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session has open items that the closing reply does not name on a `Blocked:` or `Open:` line. It merges the task store, the transcript task calls, the desktop `mcp__ccd_turn__update_plan` plan, and the recorded asks
|
|
9
|
+
- `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session has open items that the closing reply does not name on a `Blocked:` or `Open:` line. It merges the task store, the transcript task calls, the desktop `mcp__ccd_turn__update_plan` plan, and the recorded asks. A transcript item whose subject the task store holds is dropped, and every other item stays. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it. Each new desktop plan list carries the unfinished items of the list before it after its own items, so `done` and `start` positions still point at its own items. An ask stays open until a reply line `Done: #ask-N` closes it. A line `Dropped: <#ask-N or carried item> - "<quote>"` closes an ask or a carried plan item when the quote is in a prompt the user sent after that ask or that list.
|
|
10
10
|
- `session/open_task_ask_recorder.py` records each prompt in `~/.claude/open-task-asks/<session id>.json`, outside the task store. A prompt that asks for work gets the next `#ask-N` id. A question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, and a harness envelope such as `<task-notification>` get none.
|
|
11
11
|
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
12
12
|
- `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
|