claude-dev-env 8.98.6 → 8.98.8

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 (32) hide show
  1. package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
  2. package/bin/install-pstack-plugin.mjs +71 -21
  3. package/bin/install-pstack-plugin.test.mjs +46 -9
  4. package/bin/install.test.mjs +9 -9
  5. package/docs/account-broker.md +1 -1
  6. package/hooks/blocking/reply_length_gate.py +11 -2
  7. package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
  8. package/hooks/blocking/test_reply_length_gate.py +11 -0
  9. package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
  10. package/hooks/features/conduct-gates.md +3 -0
  11. package/hooks/hooks.json +10 -0
  12. package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
  13. package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
  14. package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
  15. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  16. package/hooks/session/artifact_template_pointer.py +26 -3
  17. package/hooks/session/test_artifact_template_pointer.py +16 -0
  18. package/hooks/test_transcript_skill_scan.py +30 -0
  19. package/hooks/transcript_skill_scan.py +72 -1
  20. package/package.json +1 -1
  21. package/scripts/Sync-RepoMain.ps1 +102 -4
  22. package/scripts/account_broker.py +12 -4
  23. package/scripts/durable_post_lint.py +47 -1
  24. package/scripts/durable_post_lint_config/config/constants.py +6 -0
  25. package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
  26. package/scripts/policy_lint/adapter_configuration.py +5 -0
  27. package/scripts/policy_lint/config/constants.py +1 -0
  28. package/scripts/test_account_broker.py +35 -0
  29. package/scripts/test_durable_post_lint.py +176 -3
  30. package/scripts/tests/Sync-RepoMain.Tests.ps1 +43 -0
  31. package/scripts/tests/test_adapter_configuration.py +8 -0
  32. package/scripts/tests/test_sync_repo_main_mirror.py +226 -0
@@ -0,0 +1,199 @@
1
+ """Behavior tests for the gate that holds a review thread resolve until a reply posts."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
9
+ if str(HOOKS_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
11
+
12
+ from blocking import resolve_thread_reply_gate as gate
13
+ from hooks_constants.resolve_thread_reply_gate_constants import MISSING_REPLY_REASON
14
+ from transcript_skill_scan import TranscriptToolCall
15
+
16
+ GATE_PATH = Path(__file__).resolve().parent / "resolve_thread_reply_gate.py"
17
+ RESOLVE_TOOL_NAME = "mcp__github__resolve_review_thread"
18
+ REPLY_TOOL_NAME = "mcp__github__add_reply_to_pull_request_comment"
19
+ HOOK_DENIAL_TEXT = "PreToolUse:mcp__github__add_reply_to_pull_request_comment hook error: too many sentences"
20
+
21
+
22
+ def _prompt(text: str) -> str:
23
+ return json.dumps({"type": "user", "message": {"content": text}})
24
+
25
+
26
+ def _call(tool_use_id: str, tool_name: str, tool_input: dict[str, object] | None = None) -> str:
27
+ tool_use = {"type": "tool_use", "id": tool_use_id, "name": tool_name, "input": tool_input or {}}
28
+ return json.dumps({"type": "assistant", "message": {"content": [tool_use]}})
29
+
30
+
31
+ def _result(tool_use_id: str, is_error: bool = False, text: str = "ok") -> str:
32
+ tool_result = {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error, "content": text}
33
+ return json.dumps({"type": "user", "message": {"content": [tool_result]}})
34
+
35
+
36
+ def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
37
+ transcript_path = tmp_path / "session.jsonl"
38
+ transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
39
+ return transcript_path
40
+
41
+
42
+ def _resolve_payload(transcript_path: Path) -> dict[str, object]:
43
+ return {
44
+ "hook_event_name": "PreToolUse",
45
+ "tool_name": RESOLVE_TOOL_NAME,
46
+ "tool_input": {"threadId": "PRRT_1"},
47
+ "transcript_path": str(transcript_path),
48
+ }
49
+
50
+
51
+ def _deny_reason(decision: dict[str, object] | None) -> str | None:
52
+ if decision is None:
53
+ return None
54
+ hook_output = decision["hookSpecificOutput"]
55
+ assert isinstance(hook_output, dict)
56
+ assert hook_output["permissionDecision"] == "deny"
57
+ return str(hook_output["permissionDecisionReason"])
58
+
59
+
60
+ def should_allow_a_resolve_after_a_reply_that_posted(tmp_path: Path) -> None:
61
+ transcript_path = _write_transcript(
62
+ tmp_path,
63
+ [
64
+ _prompt("Address the review threads."),
65
+ _call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
66
+ _result("reply-1"),
67
+ _call("resolve-1", RESOLVE_TOOL_NAME),
68
+ ],
69
+ )
70
+ assert gate.decision_for(_resolve_payload(transcript_path)) is None
71
+
72
+
73
+ def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
74
+ transcript_path = _write_transcript(
75
+ tmp_path,
76
+ [
77
+ _prompt("Address the review threads."),
78
+ _call("reply-1", REPLY_TOOL_NAME, {"body": "A long reply."}),
79
+ _result("reply-1", is_error=True, text=HOOK_DENIAL_TEXT),
80
+ _call("resolve-1", RESOLVE_TOOL_NAME),
81
+ ],
82
+ )
83
+ assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
84
+
85
+
86
+ def should_deny_a_resolve_with_no_reply(tmp_path: Path) -> None:
87
+ transcript_path = _write_transcript(
88
+ tmp_path,
89
+ [_prompt("Address the review threads."), _call("resolve-1", RESOLVE_TOOL_NAME)],
90
+ )
91
+ assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
92
+
93
+
94
+ def should_deny_a_second_resolve_after_one_reply(tmp_path: Path) -> None:
95
+ transcript_path = _write_transcript(
96
+ tmp_path,
97
+ [
98
+ _prompt("Address the review threads."),
99
+ _call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
100
+ _result("reply-1"),
101
+ _call("resolve-1", RESOLVE_TOOL_NAME),
102
+ _result("resolve-1"),
103
+ _call("resolve-2", RESOLVE_TOOL_NAME),
104
+ ],
105
+ )
106
+ assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
107
+
108
+
109
+ def should_allow_a_second_resolve_after_two_replies(tmp_path: Path) -> None:
110
+ transcript_path = _write_transcript(
111
+ tmp_path,
112
+ [
113
+ _prompt("Address the review threads."),
114
+ _call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
115
+ _result("reply-1"),
116
+ _call("reply-2", REPLY_TOOL_NAME, {"body": "Fixed in `def5678`."}),
117
+ _result("reply-2"),
118
+ _call("resolve-1", RESOLVE_TOOL_NAME),
119
+ _result("resolve-1"),
120
+ _call("resolve-2", RESOLVE_TOOL_NAME),
121
+ ],
122
+ )
123
+ assert gate.decision_for(_resolve_payload(transcript_path)) is None
124
+
125
+
126
+ def should_deny_a_resolve_whose_reply_came_before_the_last_prompt(tmp_path: Path) -> None:
127
+ transcript_path = _write_transcript(
128
+ tmp_path,
129
+ [
130
+ _prompt("Reply on the thread."),
131
+ _call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
132
+ _result("reply-1"),
133
+ _prompt("Now resolve the other thread."),
134
+ _call("resolve-1", RESOLVE_TOOL_NAME),
135
+ ],
136
+ )
137
+ assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
138
+
139
+
140
+ def should_allow_a_retry_after_a_resolve_error(tmp_path: Path) -> None:
141
+ transcript_path = _write_transcript(
142
+ tmp_path,
143
+ [
144
+ _prompt("Address the review threads."),
145
+ _call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
146
+ _result("reply-1"),
147
+ _call("resolve-1", RESOLVE_TOOL_NAME),
148
+ _result("resolve-1", is_error=True, text="Bad gateway"),
149
+ _call("resolve-2", RESOLVE_TOOL_NAME),
150
+ ],
151
+ )
152
+ assert gate.decision_for(_resolve_payload(transcript_path)) is None
153
+
154
+
155
+ def should_allow_a_resolve_when_the_transcript_cannot_be_read(tmp_path: Path) -> None:
156
+ assert gate.decision_for(_resolve_payload(tmp_path / "missing.jsonl")) is None
157
+
158
+
159
+ def should_ignore_tools_other_than_a_resolve(tmp_path: Path) -> None:
160
+ transcript_path = _write_transcript(tmp_path, [_prompt("Reply on the thread.")])
161
+ payload = _resolve_payload(transcript_path) | {"tool_name": REPLY_TOOL_NAME}
162
+ assert gate.decision_for(payload) is None
163
+
164
+
165
+ def should_read_a_resolve_tool_from_any_mcp_server() -> None:
166
+ assert gate.is_resolve_call("mcp__github_enterprise__resolve_review_thread")
167
+ assert not gate.is_resolve_call("mcp__github__resolve_review_threads")
168
+ assert not gate.is_resolve_call(None)
169
+
170
+
171
+ def should_count_a_gh_api_reply_command_as_a_reply() -> None:
172
+ reply_command = "gh api repos/o/r/pulls/7/comments/99/replies -f body=Fixed."
173
+ assert gate.is_reply_call(TranscriptToolCall("Bash", {"command": reply_command}, True))
174
+ graphql_command = "gh api graphql -f query='mutation { addPullRequestReviewThreadReply(input: {}) { comment { id } } }'"
175
+ assert gate.is_reply_call(TranscriptToolCall("Bash", {"command": graphql_command}, True))
176
+ assert gate.is_reply_call(TranscriptToolCall(REPLY_TOOL_NAME, {}, True))
177
+ assert not gate.is_reply_call(TranscriptToolCall("mcp__github__add_issue_comment", {}, True))
178
+
179
+
180
+ def should_report_a_posted_reply_from_transcript_lines() -> None:
181
+ all_lines = [_prompt("Go."), _call("reply-1", REPLY_TOOL_NAME), _result("reply-1")]
182
+ assert gate.has_posted_reply_since_last_resolve(all_lines)
183
+ assert not gate.has_posted_reply_since_last_resolve(all_lines[:2])
184
+
185
+
186
+ def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
187
+ transcript_path = _write_transcript(
188
+ tmp_path,
189
+ [_prompt("Address the review threads."), _call("resolve-1", RESOLVE_TOOL_NAME)],
190
+ )
191
+ completed_process = subprocess.run(
192
+ [sys.executable, str(GATE_PATH)],
193
+ input=json.dumps(_resolve_payload(transcript_path)),
194
+ capture_output=True,
195
+ text=True,
196
+ check=False,
197
+ )
198
+ assert completed_process.returncode == 0
199
+ assert _deny_reason(json.loads(completed_process.stdout)) == MISSING_REPLY_REASON
@@ -17,6 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
17
17
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
18
18
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
19
19
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
20
+ - `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on a thread that posted after the last user prompt and that no successful resolve has spent. Each successful resolve spends one posted reply. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
20
21
  - `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
21
22
  - `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
22
23
  - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
39
40
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
40
41
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
41
42
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
43
+ - `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `mcp__.*__resolve_review_thread`, timeout `10` seconds in `hooks.json`.
42
44
  - `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
43
45
  - `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
44
46
  - `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
@@ -53,6 +55,7 @@ Preconditions:
53
55
  - Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
54
56
 
55
57
  - **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
58
+ - **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, then a resolve call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
56
59
  - **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
57
60
  - **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes. The same file covers an interrupted ask, a continuation plan list that leaves items out, `Done:` and `Dropped:` lines, and a carried item that a later list marks completed.
58
61
  - **Ask recorder.** Input is a `UserPromptSubmit` payload with a work prompt, a question, a `<task-notification>` envelope, and a polite request. Run `python -m pytest packages/claude-dev-env/hooks/session/test_open_task_ask_recorder.py -q`. The adjacent test observes `ask-1` and `ask-2` for the work prompt and the request, no ask for the other two, and empty stdout.
package/hooks/hooks.json CHANGED
@@ -102,6 +102,16 @@
102
102
  }
103
103
  ]
104
104
  },
105
+ {
106
+ "matcher": "mcp__.*__resolve_review_thread",
107
+ "hooks": [
108
+ {
109
+ "type": "command",
110
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/resolve_thread_reply_gate.py",
111
+ "timeout": 10
112
+ }
113
+ ]
114
+ },
105
115
  {
106
116
  "matcher": "Bash|PowerShell",
107
117
  "hooks": [
@@ -9,6 +9,7 @@ __all__ = [
9
9
  "ARTIFACT_QUICKSTART_ACTION",
10
10
  "ARTIFACT_TEMPLATE_PATH",
11
11
  "ARTIFACT_TOOL_NAME",
12
+ "PACKAGE_ROOT_DIRECTORY",
12
13
  "HTML_PLAN_TEMPLATE_README_PATH",
13
14
  "POINTER_TEXT_TEMPLATE",
14
15
  "SKILL_NAME_INPUT_KEY",
@@ -23,7 +24,8 @@ ARTIFACT_QUICKSTART_ACTION = "quickstart"
23
24
  SKILL_TOOL_NAME = "Skill"
24
25
  SKILL_NAME_INPUT_KEY = "skill"
25
26
  ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
26
- TEMPLATES_DIRECTORY = Path(__file__).resolve().parents[2] / "docs" / "templates"
27
+ PACKAGE_ROOT_DIRECTORY = Path(__file__).resolve().parents[2]
28
+ TEMPLATES_DIRECTORY = PACKAGE_ROOT_DIRECTORY / "docs" / "templates"
27
29
  ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
28
30
  HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
29
31
  STATUS_FLOWCHART_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "status-flowchart.html"
@@ -0,0 +1,22 @@
1
+ """Constants for the gate that holds a review thread resolve until a reply on it posts."""
2
+
3
+ import re
4
+
5
+ from hooks_constants.reply_length_gate_constants import (
6
+ MAXIMUM_SENTENCE_COUNT,
7
+ MAXIMUM_WORDS_PER_SENTENCE,
8
+ )
9
+
10
+ RESOLVE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__resolve_review_thread$")
11
+ ALL_REPLY_TOOL_NAME_SUFFIXES = ("__add_reply_to_pull_request_comment",)
12
+ SHELL_COMMAND_KEY = "command"
13
+ SHELL_REPLY_COMMAND_PATTERN = re.compile(
14
+ r"\bgh\s+api\b.*(?:/comments/\d+/replies\b|addPullRequestReviewThreadReply)", re.DOTALL
15
+ )
16
+ MISSING_REPLY_REASON = (
17
+ "This review thread has no reply that posted since the last user prompt and the last resolve."
18
+ " Post the reply with add_reply_to_pull_request_comment, confirm its result came back without"
19
+ " an error, then resolve the thread. A reply that a hook refused did not post; shorten it to"
20
+ f" at most {MAXIMUM_SENTENCE_COUNT} sentences of at most {MAXIMUM_WORDS_PER_SENTENCE} words each,"
21
+ " footer included, and send it again."
22
+ )
@@ -47,3 +47,15 @@ def test_every_exported_name_resolves_to_a_module_attribute() -> None:
47
47
  ]
48
48
 
49
49
  assert all_missing_names == []
50
+
51
+
52
+ def test_every_template_path_sits_under_the_package_root() -> None:
53
+ package_root = artifact_template_pointer_constants.PACKAGE_ROOT_DIRECTORY
54
+
55
+ assert (package_root / "hooks" / "hooks_constants").is_dir()
56
+ for each_path in (
57
+ artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH,
58
+ artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH,
59
+ artifact_template_pointer_constants.STATUS_FLOWCHART_TEMPLATE_PATH,
60
+ ):
61
+ assert each_path.relative_to(package_root).parts[:2] == ("docs", "templates")
@@ -8,3 +8,5 @@ USER_ENTRY_TYPE = "user"
8
8
  COMMAND_ARGUMENTS_PATTERN = r"<command-args>(.*?)</command-args>"
9
9
  META_ENTRY_FIELD = "isMeta"
10
10
  TOOL_RESULT_BLOCK_TYPE = "tool_result"
11
+ IS_ERROR_FIELD = "is_error"
12
+ TOOL_USE_ID_FIELD = "tool_use_id"
@@ -37,6 +37,7 @@ from hooks_constants.artifact_template_pointer_constants import (
37
37
  ARTIFACT_TEMPLATE_PATH,
38
38
  ARTIFACT_TOOL_NAME,
39
39
  HTML_PLAN_TEMPLATE_README_PATH,
40
+ PACKAGE_ROOT_DIRECTORY,
40
41
  POINTER_TEXT_TEMPLATE,
41
42
  SKILL_NAME_INPUT_KEY,
42
43
  SKILL_TOOL_NAME,
@@ -53,6 +54,28 @@ from hooks_constants.spawn_readiness_hook_constants import (
53
54
  TOOL_INPUT_KEY,
54
55
  TOOL_NAME_KEY,
55
56
  )
57
+ from open_task_asks import claude_home
58
+
59
+
60
+ def installed_template_path(package_template_path: Path) -> Path:
61
+ """Return the template path that exists where this hook runs.
62
+
63
+ A source checkout keeps ``docs`` beside ``hooks``. An install puts hooks
64
+ under the agents home and docs under the Claude home, so the same relative
65
+ path is read from the Claude home there::
66
+
67
+ <checkout>/packages/claude-dev-env/docs/templates/x.md -> kept
68
+ ~/.agents/docs/templates/x.md (missing) -> ~/.claude/docs/templates/x.md
69
+
70
+ Args:
71
+ package_template_path: A template path under the package root.
72
+
73
+ Returns:
74
+ The package path when it exists, else the same relative path under the Claude home.
75
+ """
76
+ if package_template_path.exists():
77
+ return package_template_path
78
+ return claude_home() / package_template_path.relative_to(PACKAGE_ROOT_DIRECTORY)
56
79
 
57
80
 
58
81
  def starts_an_artifact_page(tool_name: object, tool_input: object) -> bool:
@@ -85,9 +108,9 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
85
108
  HOOK_SPECIFIC_OUTPUT_KEY: {
86
109
  HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
87
110
  ADDITIONAL_CONTEXT_KEY: POINTER_TEXT_TEMPLATE.format(
88
- path_of_status_flowchart=STATUS_FLOWCHART_TEMPLATE_PATH,
89
- path_of_plan_readme=HTML_PLAN_TEMPLATE_README_PATH,
90
- path_of_page_readme=ARTIFACT_TEMPLATE_PATH,
111
+ path_of_status_flowchart=installed_template_path(STATUS_FLOWCHART_TEMPLATE_PATH),
112
+ path_of_plan_readme=installed_template_path(HTML_PLAN_TEMPLATE_README_PATH),
113
+ path_of_page_readme=installed_template_path(ARTIFACT_TEMPLATE_PATH),
91
114
  ),
92
115
  }
93
116
  }
@@ -3,6 +3,8 @@ import subprocess
3
3
  import sys
4
4
  from pathlib import Path
5
5
 
6
+ import pytest
7
+
6
8
  _SESSION_DIR = Path(__file__).resolve().parent
7
9
  _HOOKS_ROOT = _SESSION_DIR.parent
8
10
  for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
@@ -99,3 +101,17 @@ def test_empty_stdin_stays_quiet() -> None:
99
101
  [sys.executable, str(HOOK_SCRIPT)], input="", capture_output=True, text=True, check=True
100
102
  )
101
103
  assert completed.stdout == ""
104
+
105
+
106
+ def test_installed_template_path_should_keep_a_package_path_that_exists() -> None:
107
+ assert artifact_template_pointer.installed_template_path(ARTIFACT_TEMPLATE_PATH) == ARTIFACT_TEMPLATE_PATH
108
+
109
+
110
+ def test_installed_template_path_should_read_the_claude_home_when_the_package_path_is_missing(
111
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
112
+ ) -> None:
113
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
114
+ missing_package_path = artifact_template_pointer.PACKAGE_ROOT_DIRECTORY / "docs" / "templates" / "absent" / "README.md"
115
+ assert artifact_template_pointer.installed_template_path(missing_package_path) == (
116
+ tmp_path / "docs" / "templates" / "absent" / "README.md"
117
+ )
@@ -9,10 +9,12 @@ if str(HOOKS_DIRECTORY) not in sys.path:
9
9
  sys.path.insert(0, str(HOOKS_DIRECTORY))
10
10
 
11
11
  from transcript_skill_scan import (
12
+ TranscriptToolCall,
12
13
  invokes_skill_with_argument,
13
14
  is_skill_loaded_after_last_compaction,
14
15
  is_skill_loaded_since_last_prompt,
15
16
  skill_invocation_status,
17
+ tool_calls_since_last_prompt,
16
18
  )
17
19
 
18
20
 
@@ -158,3 +160,31 @@ def test_slash_command_prompt_loads_the_skill_for_its_own_turn() -> None:
158
160
  assert is_skill_loaded_since_last_prompt([_prompt_entry("First."), _prompt_entry(marker)], ("verify",), (marker,))
159
161
  compaction = json.dumps({"subtype": "compact_boundary"})
160
162
  assert not is_skill_loaded_since_last_prompt([_prompt_entry(marker), compaction], ("verify",), (marker,))
163
+
164
+
165
+ def should_list_each_tool_call_since_the_last_prompt_with_its_outcome() -> None:
166
+ def _call(tool_use_id: str, name: str) -> str:
167
+ return json.dumps(
168
+ {"type": "assistant", "message": {"content": [{"type": "tool_use", "id": tool_use_id, "name": name, "input": {}}]}}
169
+ )
170
+
171
+ def _result(tool_use_id: str, is_error: bool) -> str:
172
+ tool_result = {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error, "content": "x"}
173
+ return json.dumps({"type": "user", "message": {"content": [tool_result]}})
174
+
175
+ lines = [
176
+ json.dumps({"type": "user", "message": {"content": "First."}}),
177
+ _call("old", "Read"),
178
+ _result("old", False),
179
+ json.dumps({"type": "user", "message": {"content": "Second."}}),
180
+ _call("ok", "Bash"),
181
+ _result("ok", False),
182
+ _call("denied", "Edit"),
183
+ _result("denied", True),
184
+ _call("pending", "Write"),
185
+ ]
186
+ assert tool_calls_since_last_prompt(lines) == [
187
+ TranscriptToolCall("Bash", {}, True),
188
+ TranscriptToolCall("Edit", {}, False),
189
+ TranscriptToolCall("Write", {}, False),
190
+ ]
@@ -1,23 +1,37 @@
1
- """Find a skill invocation after the last transcript compaction, or one with a given first argument anywhere."""
1
+ """Find a skill invocation after the last transcript compaction, or one with a given first argument anywhere.
2
+
3
+ It also lists the tool calls made since the last user prompt, each with whether its result came back without an error.
4
+ """
2
5
 
3
6
  from __future__ import annotations
4
7
 
5
8
  import json
6
9
  import re
7
10
  from collections.abc import Collection, Iterable, Iterator
11
+ from typing import NamedTuple
8
12
 
9
13
  from hooks_constants.transcript_skill_scan_constants import (
10
14
  ASSISTANT_ENTRY_TYPE,
11
15
  COMMAND_ARGUMENTS_PATTERN,
12
16
  COMPACT_BOUNDARY_SUBTYPE,
17
+ IS_ERROR_FIELD,
13
18
  META_ENTRY_FIELD,
14
19
  SKILL_TOOL_NAME,
15
20
  TOOL_RESULT_BLOCK_TYPE,
16
21
  TOOL_USE_BLOCK_TYPE,
22
+ TOOL_USE_ID_FIELD,
17
23
  USER_ENTRY_TYPE,
18
24
  )
19
25
 
20
26
 
27
+ class TranscriptToolCall(NamedTuple):
28
+ """One tool call in a transcript: its name, its input, and whether its result came back without an error."""
29
+
30
+ name: str
31
+ tool_input: dict[str, object]
32
+ succeeded: bool
33
+
34
+
21
35
  def _user_command_text(all_entry_fields: dict[str, object]) -> str | None:
22
36
  message = all_entry_fields.get("message")
23
37
  if all_entry_fields.get("type") != USER_ENTRY_TYPE or not isinstance(message, dict):
@@ -225,3 +239,60 @@ def invokes_skill_with_argument(
225
239
  if all_entry_fields and _invokes_skill_with_argument(all_entry_fields, skill_names, argument_word, command_marker):
226
240
  return True
227
241
  return False
242
+
243
+
244
+ def _content_blocks(all_entry_fields: dict[str, object], entry_type: str) -> list[dict[str, object]]:
245
+ message = all_entry_fields.get("message")
246
+ if all_entry_fields.get("type") != entry_type or not isinstance(message, dict):
247
+ return []
248
+ content = message.get("content")
249
+ return [each_block for each_block in (content if isinstance(content, list) else []) if isinstance(each_block, dict)]
250
+
251
+
252
+ def _tool_use_calls(all_entry_fields: dict[str, object]) -> list[tuple[object, str, dict[str, object]]]:
253
+ all_tool_use_calls: list[tuple[object, str, dict[str, object]]] = []
254
+ for each_block in _content_blocks(all_entry_fields, ASSISTANT_ENTRY_TYPE):
255
+ tool_name = each_block.get("name")
256
+ tool_input = each_block.get("input")
257
+ if each_block.get("type") == TOOL_USE_BLOCK_TYPE and isinstance(tool_name, str):
258
+ all_tool_use_calls.append((each_block.get("id"), tool_name, tool_input if isinstance(tool_input, dict) else {}))
259
+ return all_tool_use_calls
260
+
261
+
262
+ def _succeeded_result_ids(all_entry_fields: dict[str, object]) -> set[object]:
263
+ return {
264
+ each_block.get(TOOL_USE_ID_FIELD)
265
+ for each_block in _content_blocks(all_entry_fields, USER_ENTRY_TYPE)
266
+ if each_block.get("type") == TOOL_RESULT_BLOCK_TYPE and each_block.get(IS_ERROR_FIELD) is not True
267
+ }
268
+
269
+
270
+ def tool_calls_since_last_prompt(all_transcript_lines: Iterable[str]) -> list[TranscriptToolCall]:
271
+ """Return the tool calls after the last user prompt, in order, each marked by whether it succeeded.
272
+
273
+ ::
274
+
275
+ prompt, reply call, its result without an error -> [reply succeeded]
276
+ prompt, reply call, its result with is_error -> [reply failed]
277
+ prompt, resolve call with no result yet -> [resolve failed]
278
+ reply call and result, then a new prompt -> []
279
+
280
+ A hook that denies a call leaves an errored result, so a denied call does
281
+ not count as a success. A tool result leaves the turn open.
282
+
283
+ Args:
284
+ all_transcript_lines: JSON transcript entries, one per line.
285
+ """
286
+ all_calls: list[tuple[object, str, dict[str, object]]] = []
287
+ all_succeeded_ids: set[object] = set()
288
+ all_entries = (each_entry for each_entry in map(_parsed_entry, all_transcript_lines) if each_entry is not None)
289
+ for each_entry_fields in all_entries:
290
+ if _is_user_prompt(each_entry_fields):
291
+ all_calls, all_succeeded_ids = [], set()
292
+ continue
293
+ all_calls.extend(_tool_use_calls(each_entry_fields))
294
+ all_succeeded_ids.update(_succeeded_result_ids(each_entry_fields))
295
+ return [
296
+ TranscriptToolCall(each_name, each_input, each_id is not None and each_id in all_succeeded_ids)
297
+ for each_id, each_name, each_input in all_calls
298
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.98.6",
3
+ "version": "8.98.8",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {