claude-dev-env 2.24.0 → 2.26.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.
- package/.agents/skills/pr-cleanup/SKILL.md +1 -0
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +76 -0
- package/.agents/skills/pr-title-description/SKILL.md +92 -0
- package/bin/ever-shipped-skills.mjs +2 -0
- package/hooks/git-hooks/AGENTS.md +2 -1
- package/hooks/git-hooks/gate_utils.py +17 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +3 -0
- package/hooks/git-hooks/post_commit.py +31 -1
- package/hooks/git-hooks/pre_push.py +7 -10
- package/hooks/git-hooks/pull_request_handoff.py +32 -0
- package/hooks/git-hooks/test_gate_utils.py +38 -0
- package/hooks/git-hooks/test_post_commit.py +87 -0
- package/hooks/hooks_constants/session_start_injector.py +11 -4
- package/hooks/hooks_constants/test_session_start_injector.py +4 -2
- package/hooks/session/issue_tracker_session_starter.py +3 -2
- package/hooks/session/orchestrator_auto_starter.py +3 -2
- package/hooks/session/task_list_loop_starter.py +10 -1
- package/hooks/session/test_issue_tracker_session_starter.py +3 -1
- package/hooks/session/test_orchestrator_auto_starter.py +5 -3
- package/hooks/session/test_task_list_loop_starter.py +10 -8
- package/hooks/session/test_untracked_repo_detector.py +7 -5
- package/hooks/session/test_working_style_prompt.py +5 -3
- package/hooks/session/untracked_repo_detector.py +10 -1
- package/hooks/session/working_style_prompt.py +10 -1
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +4 -2
- package/scripts/tests/test_codex_compat_materializer.py +2 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-plain-language-cleanup
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
description: Review every line of every changed PR file and trim words, names, docs, comments, and code while keeping real behavior.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PR plain-language cleanup
|
|
8
|
+
|
|
9
|
+
Review and trim this pull request:
|
|
10
|
+
|
|
11
|
+
<PR LINK>
|
|
12
|
+
|
|
13
|
+
If no link is given, find the clear PR in the current context. If none is clear, ask for one.
|
|
14
|
+
|
|
15
|
+
Read the full PR, full diff, every changed file, changed tests and docs, and all repo rules that apply.
|
|
16
|
+
|
|
17
|
+
Find the changed files. Send one capable agent to each file. If there are more files than agents, use small batches.
|
|
18
|
+
|
|
19
|
+
Each agent owns one file. Read every line. Review code, tests, names, comments, docstrings, strings, notes, Markdown, and examples.
|
|
20
|
+
|
|
21
|
+
Trim filler, repeats, vague words, needless code, needless names, dead notes, jargon, and steps that add no value. Use few words and small words.
|
|
22
|
+
|
|
23
|
+
Keep real behavior, checks, data, error detail, context, tests, and public names unless a safe full rename is clear. Do not change behavior for style. Do not shorten a name when it loses meaning.
|
|
24
|
+
|
|
25
|
+
Follow the repo rules:
|
|
26
|
+
|
|
27
|
+
- Name modules for their capability.
|
|
28
|
+
- Name functions for their action.
|
|
29
|
+
- Name variables for the value they hold.
|
|
30
|
+
- Name classes for what they are.
|
|
31
|
+
- Use capability names for shared code.
|
|
32
|
+
- Keep workflow words on workflow code.
|
|
33
|
+
- Keep comments and docs true to the code.
|
|
34
|
+
- Use real data and real behavior in tests.
|
|
35
|
+
|
|
36
|
+
## Hard limits
|
|
37
|
+
|
|
38
|
+
This is a non-material, text-only trim.
|
|
39
|
+
|
|
40
|
+
DO NOT:
|
|
41
|
+
|
|
42
|
+
- run `e-simplify`, `e-code-review`, or `source-command-sr-loop`;
|
|
43
|
+
- run `pr-cleanup`, `pr-refinement`, `pr-small-cl`, placement audits, extraction audits, or capability-name audits;
|
|
44
|
+
- run review loops, advisor gates, commit gates, promotion gates, or any other extra cleanup;
|
|
45
|
+
- hunt bugs, change behavior, change APIs, change data, add features, or split the PR;
|
|
46
|
+
- turn a text trim into a code fix.
|
|
47
|
+
|
|
48
|
+
Run only the smallest checks needed for the files changed. Keep the repo rules. If a material issue appears, report it and stop.
|
|
49
|
+
|
|
50
|
+
If a change needs another file, report it to the main agent. The main agent owns cross-file changes.
|
|
51
|
+
|
|
52
|
+
After the file agents finish, apply safe edits, fix cross-file names and links, search for old names and wording, and read every changed file and the full diff again.
|
|
53
|
+
|
|
54
|
+
Run only the smallest relevant check for the files changed. Fix failures. Check the final diff against the repo rules.
|
|
55
|
+
|
|
56
|
+
Do not invent behavior, tests, or benefits. Stop and ask only when a change could alter behavior, break a public API, or exceed the PR scope.
|
|
57
|
+
|
|
58
|
+
Commit and push the changes to the PR branch.
|
|
59
|
+
|
|
60
|
+
Return:
|
|
61
|
+
|
|
62
|
+
## What changed
|
|
63
|
+
|
|
64
|
+
Short list of the main trims.
|
|
65
|
+
|
|
66
|
+
## Files checked
|
|
67
|
+
|
|
68
|
+
Every file reviewed.
|
|
69
|
+
|
|
70
|
+
## Verification
|
|
71
|
+
|
|
72
|
+
Checks run and results.
|
|
73
|
+
|
|
74
|
+
## Remaining items
|
|
75
|
+
|
|
76
|
+
Only items that need a user choice.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-title-description
|
|
3
|
+
description: Review one or more pull requests and write plain titles and descriptions from the full diff.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Accept one or more pull request links as arguments. Apply the prompt below to each link.
|
|
8
|
+
|
|
9
|
+
With no argument, use the current task, browser, worktree, or GitHub context to find the pull request or pull requests. If the context does not show a clear pull request, ask for a pull request link.
|
|
10
|
+
|
|
11
|
+
Review this pull request: <PR link>
|
|
12
|
+
|
|
13
|
+
Read the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.
|
|
14
|
+
|
|
15
|
+
Find the main thing this adds for a user, caller, or operator.
|
|
16
|
+
|
|
17
|
+
Write like the reader is smart but knows nothing about this code. Pretend they asked you to explain it like they are five and feel stupid. If a word needs another sentence to explain it, replace the word — or turn it into a tiny story.
|
|
18
|
+
|
|
19
|
+
Your job is not a dry API blurb. Your job is an illustrative before/after a person can picture.
|
|
20
|
+
|
|
21
|
+
Gold voice for titles and “What this adds”:
|
|
22
|
+
|
|
23
|
+
The theme package has a phone icon somewhere. We need to find it.
|
|
24
|
+
|
|
25
|
+
Before: yell “anyone know how to find icons?” and wait for a hand.
|
|
26
|
+
|
|
27
|
+
After: call the finder by its name tag: “icon finder, go.”
|
|
28
|
+
|
|
29
|
+
That’s all “operation id” is — the finder’s name tag.
|
|
30
|
+
|
|
31
|
+
Rules for that voice:
|
|
32
|
+
|
|
33
|
+
Start from a concrete scene (what’s in someone’s hand, what they’re hunting for).
|
|
34
|
+
Prefer Before / After when the change is “how you ask” or “how you look it up.”
|
|
35
|
+
Use picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.
|
|
36
|
+
Prefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”
|
|
37
|
+
When the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:
|
|
38
|
+
Drop it and say what the person does with their hands, or
|
|
39
|
+
Keep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.
|
|
40
|
+
Never leave jargon unexplained. Never write as if the reader already knows the jargon.
|
|
41
|
+
Add a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).
|
|
42
|
+
Few words. Small words. Clear on first read.
|
|
43
|
+
Return:
|
|
44
|
+
|
|
45
|
+
Recommended title
|
|
46
|
+
|
|
47
|
+
Few words. Small words. Clear on first read.
|
|
48
|
+
Say what you hand it, what it does, and what you get back — or the Before/After of how you ask.
|
|
49
|
+
Prefer picture words and spoken results.
|
|
50
|
+
Good title: “Call the icon finder by its name tag instead of asking around”
|
|
51
|
+
Good title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”
|
|
52
|
+
Bad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.
|
|
53
|
+
Use “Add” only if it helps. Do not force it.
|
|
54
|
+
No vague words like “improve,” “enhance,” or “update.”
|
|
55
|
+
Return one title only.
|
|
56
|
+
Why this title fits
|
|
57
|
+
|
|
58
|
+
1–2 short sentences in the same voice.
|
|
59
|
+
If needed, one Before / After beat, then what you get back.
|
|
60
|
+
If this section is needed to decode the title, rewrite the title.
|
|
61
|
+
Evidence
|
|
62
|
+
|
|
63
|
+
Key files or functions that support the title.
|
|
64
|
+
If you cannot read the PR or full diff, say so and do not propose a title.
|
|
65
|
+
|
|
66
|
+
Then write a short, paste-ready PR description.
|
|
67
|
+
|
|
68
|
+
Use this structure:
|
|
69
|
+
|
|
70
|
+
What this adds
|
|
71
|
+
1–2 short paragraphs in that illustrative voice. Lead with a scene when you can (“The theme package has a phone icon somewhere…”). If the change is a new way to ask, use Before / After. Say what you give it, what it does with its hands, and what you get back. If an abstract machine-word must appear, gloss it immediately (“That’s all X is — …”). Describe the job people can see. Do not describe inner machinery. Mention a check only if it is central to the feature. If there are many changes, keep the main one. Mention others only if they explain the main one.
|
|
72
|
+
|
|
73
|
+
Why
|
|
74
|
+
One short paragraph. Same plain level. Say who needs this and what goes wrong without it (the “yell and wait for a hand” problem). If this is needed to decode the feature, rewrite “What this adds.”
|
|
75
|
+
|
|
76
|
+
Verification
|
|
77
|
+
List only proof from the PR, CI, or your own checks.
|
|
78
|
+
|
|
79
|
+
Reported in the PR
|
|
80
|
+
|
|
81
|
+
…
|
|
82
|
+
Checked by you / CI here
|
|
83
|
+
|
|
84
|
+
…
|
|
85
|
+
Rules:
|
|
86
|
+
|
|
87
|
+
Few words. Small words. Clear on first read. ELI5 I-am-stupid mark.
|
|
88
|
+
Do not invent behavior, tests, or benefits.
|
|
89
|
+
Strip branch names, hashes, draft notes, agent notes, and merge notes.
|
|
90
|
+
No vague words like “improve,” “enhance,” or “update.”
|
|
91
|
+
Illustrative first. Abstract labels only if glossed in the same breath.
|
|
92
|
+
Use the recommended title and paste-ready description to run `gh pr edit <PR link> --title ... --body-file ...`, then run `gh pr view <PR link> --json title,body` to confirm the saved title and body. Report the saved result.
|
|
@@ -9,7 +9,8 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
|
|
|
9
9
|
| `pre_commit.py` | `pre-commit` | Runs immediate CODE_RULES and terminology validation over staged changes; exits 1 when any staged file has a blocking violation. CI runs package tests for package Python changes. |
|
|
10
10
|
| `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate. An existing branch's gate base is the merge base with the remote default branch; the gate process still diffs that base against checkout HEAD, so the surface matches the pushed tip only when HEAD is that tip. |
|
|
11
11
|
| `pre_push_base_reference.py` | — | Resolves a usable gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then turns a symbolic default-branch head into a reference that git can resolve |
|
|
12
|
-
| `post_commit.py` | `post-commit` | Runs
|
|
12
|
+
| `post_commit.py` | `post-commit` | Runs bookkeeping, then best-effort reminds the agent about its PR |
|
|
13
|
+
| `pull_request_handoff.py` | — | Prints the full PR title and description handoff prompt |
|
|
13
14
|
| `gate_utils.py` | — | Shared helpers: resolves the gate script path, checks that the path is a safe regular file |
|
|
14
15
|
| `test_config.py` | — | Test configuration helpers |
|
|
15
16
|
| `test_gate_utils.py` | — | Tests for `gate_utils.py` |
|
|
@@ -6,14 +6,29 @@ import os
|
|
|
6
6
|
import stat
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
import git_hooks_constants
|
|
9
10
|
from git_hooks_constants import (
|
|
10
|
-
ALL_GATE_SCRIPT_RELATIVE_PATH,
|
|
11
11
|
CLAUDE_HOME_DEFAULT_SUBDIRECTORY,
|
|
12
12
|
CLAUDE_HOME_ENV_VAR,
|
|
13
13
|
GATE_PATH_OVERRIDE_ENV_VAR,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def load_gate_script_relative_path(
|
|
18
|
+
constants_module: object | None = None,
|
|
19
|
+
) -> tuple[str, ...]:
|
|
20
|
+
"""Return the gate script path segments from git_hooks_constants.
|
|
21
|
+
|
|
22
|
+
Prefer ALL_GATE_SCRIPT_RELATIVE_PATH. Fall back to GATE_SCRIPT_RELATIVE_PATH
|
|
23
|
+
when an older installed constants module has not been updated yet.
|
|
24
|
+
"""
|
|
25
|
+
module = git_hooks_constants if constants_module is None else constants_module
|
|
26
|
+
try:
|
|
27
|
+
return module.ALL_GATE_SCRIPT_RELATIVE_PATH
|
|
28
|
+
except AttributeError:
|
|
29
|
+
return module.GATE_SCRIPT_RELATIVE_PATH
|
|
30
|
+
|
|
31
|
+
|
|
17
32
|
def resolve_gate_script_path() -> tuple[Path, Path | None]:
|
|
18
33
|
"""Return (gate_path, exact_allowed_override_or_none).
|
|
19
34
|
|
|
@@ -34,7 +49,7 @@ def resolve_gate_script_path() -> tuple[Path, Path | None]:
|
|
|
34
49
|
claude_home_directory = Path(claude_home_override).resolve()
|
|
35
50
|
else:
|
|
36
51
|
claude_home_directory = Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY
|
|
37
|
-
gate_path = claude_home_directory.joinpath(*
|
|
52
|
+
gate_path = claude_home_directory.joinpath(*load_gate_script_relative_path())
|
|
38
53
|
return gate_path, None
|
|
39
54
|
|
|
40
55
|
|
|
@@ -97,6 +97,9 @@ GIT_REV_PARSE_VERIFY_FLAG: str = "--verify"
|
|
|
97
97
|
GIT_QUIET_FLAG: str = "--quiet"
|
|
98
98
|
GIT_SYMBOLIC_REFERENCE_SUBCOMMAND: str = "symbolic-ref"
|
|
99
99
|
GIT_COMMAND_TIMEOUT_SECONDS: int = 30
|
|
100
|
+
GH_EXECUTABLE_NAME: str = "gh"
|
|
101
|
+
GH_PR_VIEW_TIMEOUT_SECONDS: int = 5
|
|
102
|
+
GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = ("pr", "view", "--json", "url", "--jq", ".url")
|
|
100
103
|
GIT_FOR_EACH_REF_SUBCOMMAND: str = "for-each-ref"
|
|
101
104
|
GIT_REFERENCE_SHORT_NAME_FORMAT_ARGUMENT: str = "--format=%(refname:short)"
|
|
102
105
|
COMMIT_OBJECT_NAME_SUFFIX: str = "^{commit}"
|
|
@@ -17,7 +17,14 @@ import sys
|
|
|
17
17
|
from enum import StrEnum
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
from git_hooks_constants import
|
|
20
|
+
from git_hooks_constants import (
|
|
21
|
+
GH_EXECUTABLE_NAME,
|
|
22
|
+
GH_PR_VIEW_ARGUMENTS,
|
|
23
|
+
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
24
|
+
GIT_COMMAND_SUCCESS_EXIT_CODE,
|
|
25
|
+
GIT_EXECUTABLE_NAME,
|
|
26
|
+
)
|
|
27
|
+
from pull_request_handoff import build_pull_request_reminder
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
class ParentPointerStatus(StrEnum):
|
|
@@ -163,6 +170,24 @@ def update_parent_pointer(
|
|
|
163
170
|
return ParentPointerStatus.UPDATED, ""
|
|
164
171
|
|
|
165
172
|
|
|
173
|
+
def print_pull_request_reminder(repo_dir: Path) -> None:
|
|
174
|
+
"""Print a reminder when the committed repository has an open pull request."""
|
|
175
|
+
try:
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
[GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
|
|
178
|
+
cwd=repo_dir,
|
|
179
|
+
check=False,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
183
|
+
)
|
|
184
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
185
|
+
return
|
|
186
|
+
pull_request_url = result.stdout.strip()
|
|
187
|
+
if result.returncode == 0 and pull_request_url:
|
|
188
|
+
print(build_pull_request_reminder(pull_request_url))
|
|
189
|
+
|
|
190
|
+
|
|
166
191
|
def main() -> int:
|
|
167
192
|
"""Update a parent repository after a submodule commit."""
|
|
168
193
|
repo_path_text = run_git_from_current_directory("rev-parse", "--show-toplevel")
|
|
@@ -172,12 +197,14 @@ def main() -> int:
|
|
|
172
197
|
repo_dir = Path(repo_path_text).resolve()
|
|
173
198
|
parent_repo = find_parent_repo(repo_dir)
|
|
174
199
|
if parent_repo is None:
|
|
200
|
+
print_pull_request_reminder(repo_dir)
|
|
175
201
|
return 0
|
|
176
202
|
|
|
177
203
|
commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
|
|
178
204
|
commit_hash = run_git("rev-parse", "HEAD", cwd=repo_dir)
|
|
179
205
|
short_commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
|
|
180
206
|
if not commit_hash or not short_commit_hash:
|
|
207
|
+
print_pull_request_reminder(repo_dir)
|
|
181
208
|
return 0
|
|
182
209
|
|
|
183
210
|
print()
|
|
@@ -195,14 +222,17 @@ def main() -> int:
|
|
|
195
222
|
print("Parent update failed.")
|
|
196
223
|
if parent_pointer_diagnostic:
|
|
197
224
|
print(f"Git diagnostic: {parent_pointer_diagnostic}")
|
|
225
|
+
print_pull_request_reminder(repo_dir)
|
|
198
226
|
return 0
|
|
199
227
|
if parent_pointer_status is ParentPointerStatus.UNCHANGED:
|
|
200
228
|
print("Parent already up to date.")
|
|
229
|
+
print_pull_request_reminder(repo_dir)
|
|
201
230
|
return 0
|
|
202
231
|
|
|
203
232
|
print("Parent updated successfully.")
|
|
204
233
|
print("================================")
|
|
205
234
|
print()
|
|
235
|
+
print_pull_request_reminder(repo_dir)
|
|
206
236
|
|
|
207
237
|
return 0
|
|
208
238
|
|
|
@@ -87,14 +87,13 @@ from pre_push_base_reference import (
|
|
|
87
87
|
resolve_remote_name_from_arguments,
|
|
88
88
|
resolve_usable_base_reference,
|
|
89
89
|
)
|
|
90
|
+
from pull_request_handoff import build_pull_request_reminder, get_pull_request_url
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def _report_unavailable_git(launch_error: Exception) -> int:
|
|
93
94
|
"""Report a git that would not run, and hand back the exit code to use."""
|
|
94
95
|
git_command_unavailable_message = GIT_COMMAND_UNAVAILABLE_MESSAGE
|
|
95
|
-
sys.stderr.write(
|
|
96
|
-
git_command_unavailable_message.format(error=launch_error) + "\n"
|
|
97
|
-
)
|
|
96
|
+
sys.stderr.write(git_command_unavailable_message.format(error=launch_error) + "\n")
|
|
98
97
|
return GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
99
98
|
|
|
100
99
|
|
|
@@ -145,8 +144,7 @@ def is_all_zeros_object_name(object_name: str) -> bool:
|
|
|
145
144
|
if not stripped_object_name:
|
|
146
145
|
return True
|
|
147
146
|
return all(
|
|
148
|
-
each_character == all_zeros_object_name_character
|
|
149
|
-
for each_character in stripped_object_name
|
|
147
|
+
each_character == all_zeros_object_name_character for each_character in stripped_object_name
|
|
150
148
|
)
|
|
151
149
|
|
|
152
150
|
|
|
@@ -385,9 +383,7 @@ def resolve_default_branch_merge_base(
|
|
|
385
383
|
default_branch_reference = resolve_default_branch_reference(remote_name)
|
|
386
384
|
if default_branch_reference is None:
|
|
387
385
|
return None
|
|
388
|
-
default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(
|
|
389
|
-
remote=remote_name, branch=""
|
|
390
|
-
)
|
|
386
|
+
default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(remote=remote_name, branch="")
|
|
391
387
|
default_branch_name = default_branch_reference.removeprefix(default_branch_prefix)
|
|
392
388
|
if remote_branch_name == default_branch_name:
|
|
393
389
|
return None
|
|
@@ -514,8 +510,6 @@ def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
|
|
|
514
510
|
return completion.returncode
|
|
515
511
|
|
|
516
512
|
|
|
517
|
-
|
|
518
|
-
|
|
519
513
|
def main() -> int:
|
|
520
514
|
stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
|
|
521
515
|
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
@@ -576,6 +570,9 @@ def main() -> int:
|
|
|
576
570
|
code_rules_exit_code = invoke_gate(gate_script_path, usable_base_reference)
|
|
577
571
|
if code_rules_exit_code != 0:
|
|
578
572
|
return code_rules_exit_code
|
|
573
|
+
pull_request_url = get_pull_request_url(Path.cwd())
|
|
574
|
+
if pull_request_url:
|
|
575
|
+
print(build_pull_request_reminder(pull_request_url))
|
|
579
576
|
return 0
|
|
580
577
|
|
|
581
578
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from git_hooks_constants import (
|
|
7
|
+
GH_EXECUTABLE_NAME,
|
|
8
|
+
GH_PR_VIEW_ARGUMENTS,
|
|
9
|
+
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_pull_request_url(repo_dir: Path) -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
[GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
|
|
17
|
+
cwd=repo_dir,
|
|
18
|
+
check=False,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
22
|
+
)
|
|
23
|
+
except (OSError, subprocess.SubprocessError):
|
|
24
|
+
return None
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
return None
|
|
27
|
+
return result.stdout.strip() or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_pull_request_reminder(url: str) -> str:
|
|
31
|
+
prompt = "Review this pull request: <PR link>\n\nRead the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.\n\nFind the main thing this adds for a user, caller, or operator.\n\nWrite like the reader is smart but knows nothing about this code. Pretend they asked you to explain it like they are five and feel stupid. If a word needs another sentence to explain it, replace the word — or turn it into a tiny story.\n\nYour job is not a dry API blurb. Your job is an illustrative before/after a person can picture.\n\nGold voice for titles and “What this adds”:\n\nThe theme package has a phone icon somewhere. We need to find it.\n\nBefore: yell “anyone know how to find icons?” and wait for a hand.\n\nAfter: call the finder by its name tag: “icon finder, go.”\n\nThat’s all “operation id” is — the finder’s name tag.\n\nRules for that voice:\n\nStart from a concrete scene (what’s in someone’s hand, what they’re hunting for).\nPrefer Before / After when the change is “how you ask” or “how you look it up.”\nUse picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.\nPrefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”\nWhen the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:\nDrop it and say what the person does with their hands, or\nKeep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.\nNever leave jargon unexplained. Never write as if the reader already knows the jargon.\nAdd a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).\nFew words. Small words. Clear on first read.\nReturn:\n\nRecommended title\n\nFew words. Small words. Clear on first read.\nSay what you hand it, what it does, and what you get back — or the Before/After of how you ask.\nPrefer picture words and spoken results.\nGood title: “Call the icon finder by its name tag instead of asking around”\nGood title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”\nBad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.\nUse “Add” only if it helps. Do not force it.\nNo vague words like “improve,” “enhance,” or “update.”\nReturn one title only.\nWhy this title fits\n\n1–2 short sentences in the same voice.\nIf needed, one Before / After beat, then what you get back.\nIf this section is needed to decode the title, rewrite the title.\nEvidence\n\nKey files or functions that support the title.\nIf you cannot read the PR or full diff, say so and do not propose a title.\n\nThen write a short, paste-ready PR description.\n\nUse this structure:\n\nWhat this adds\n1–2 short paragraphs in that illustrative voice. Lead with a scene when you can (“The theme package has a phone icon somewhere…”). If the change is a new way to ask, use Before / After. Say what you give it, what it does with its hands, and what you get back. If an abstract machine-word must appear, gloss it immediately (“That’s all X is — …”). Describe the job people can see. Do not describe inner machinery. Mention a check only if it is central to the feature. If there are many changes, keep the main one. Mention others only if they explain the main one.\n\nWhy\nOne short paragraph. Same plain level. Say who needs this and what goes wrong without it (the “yell and wait for a hand” problem). If this is needed to decode the feature, rewrite “What this adds.”\n\nVerification\nList only proof from the PR, CI, or your own checks.\n\nReported in the PR\n\n…\nChecked by you / CI here\n\n…\nRules:\n\nFew words. Small words. Clear on first read. ELI5 I-am-stupid mark.\nDo not invent behavior, tests, or benefits.\nStrip branch names, hashes, draft notes, agent notes, and merge notes.\nNo vague words like “improve,” “enhance,” or “update.”\nIllustrative first. Abstract labels only if glossed in the same breath.\nUse the recommended title and paste-ready description to run `gh pr edit <PR link> --title ... --body-file ...`, then run `gh pr view <PR link> --json title,body` to confirm the saved title and body. Report the saved result."
|
|
32
|
+
return prompt.replace("<PR link>", url)
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
import types
|
|
6
7
|
|
|
7
8
|
import gate_utils
|
|
8
9
|
import pytest
|
|
10
|
+
from git_hooks_constants import ALL_GATE_SCRIPT_RELATIVE_PATH
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def test_resolve_gate_script_path_uses_override_env_var_when_set(
|
|
@@ -218,3 +220,39 @@ def test_is_safe_regular_file_rejects_path_outside_claude_home_env_trust_root(
|
|
|
218
220
|
is_safe = gate_utils.is_safe_regular_file(outside_path, None)
|
|
219
221
|
|
|
220
222
|
assert not is_safe
|
|
223
|
+
|
|
224
|
+
def test_git_hooks_constants_exports_canonical_gate_script_relative_path() -> None:
|
|
225
|
+
assert ALL_GATE_SCRIPT_RELATIVE_PATH == (
|
|
226
|
+
"_shared",
|
|
227
|
+
"pr-loop",
|
|
228
|
+
"scripts",
|
|
229
|
+
"code_rules_gate.py",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_load_gate_script_relative_path_prefers_canonical_name() -> None:
|
|
234
|
+
constants_module = types.SimpleNamespace(
|
|
235
|
+
ALL_GATE_SCRIPT_RELATIVE_PATH=("_shared", "pr-loop", "scripts", "code_rules_gate.py"),
|
|
236
|
+
GATE_SCRIPT_RELATIVE_PATH=("legacy", "path.py"),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
loaded_path = gate_utils.load_gate_script_relative_path(constants_module)
|
|
240
|
+
|
|
241
|
+
assert loaded_path == ("_shared", "pr-loop", "scripts", "code_rules_gate.py")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_load_gate_script_relative_path_falls_back_to_legacy_name() -> None:
|
|
245
|
+
constants_module = types.SimpleNamespace(
|
|
246
|
+
GATE_SCRIPT_RELATIVE_PATH=("_shared", "pr-loop", "scripts", "code_rules_gate.py"),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
loaded_path = gate_utils.load_gate_script_relative_path(constants_module)
|
|
250
|
+
|
|
251
|
+
assert loaded_path == ("_shared", "pr-loop", "scripts", "code_rules_gate.py")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_gate_utils_module_import_resolves_live_constants() -> None:
|
|
255
|
+
loaded_path = gate_utils.load_gate_script_relative_path()
|
|
256
|
+
|
|
257
|
+
assert loaded_path == ALL_GATE_SCRIPT_RELATIVE_PATH
|
|
258
|
+
|
|
@@ -201,3 +201,90 @@ def test_main_prints_git_failure_diagnostic(
|
|
|
201
201
|
|
|
202
202
|
assert post_commit.main() == 0
|
|
203
203
|
assert "Git diagnostic: fatal: fixture commit failure" in capsys.readouterr().out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.parametrize(
|
|
207
|
+
"completed_process",
|
|
208
|
+
[
|
|
209
|
+
subprocess.CompletedProcess(["gh"], 1, "", "no pull request"),
|
|
210
|
+
subprocess.CompletedProcess(["gh"], 1, "", "auth failed"),
|
|
211
|
+
],
|
|
212
|
+
)
|
|
213
|
+
def test_pull_request_reminder_is_silent_without_successful_url(
|
|
214
|
+
tmp_path: Path,
|
|
215
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
216
|
+
capsys: pytest.CaptureFixture[str],
|
|
217
|
+
completed_process: subprocess.CompletedProcess[str],
|
|
218
|
+
) -> None:
|
|
219
|
+
monkeypatch.setattr(post_commit.subprocess, "run", lambda *args, **kwargs: completed_process)
|
|
220
|
+
|
|
221
|
+
post_commit.print_pull_request_reminder(tmp_path)
|
|
222
|
+
|
|
223
|
+
assert capsys.readouterr().out == ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_pull_request_reminder_prints_url(
|
|
227
|
+
tmp_path: Path,
|
|
228
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
229
|
+
capsys: pytest.CaptureFixture[str],
|
|
230
|
+
) -> None:
|
|
231
|
+
monkeypatch.setattr(
|
|
232
|
+
post_commit.subprocess,
|
|
233
|
+
"run",
|
|
234
|
+
lambda *args, **kwargs: subprocess.CompletedProcess(
|
|
235
|
+
["gh"], 0, "https://github.com/example/repo/pull/1\n", ""
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
post_commit.print_pull_request_reminder(tmp_path)
|
|
240
|
+
|
|
241
|
+
output = capsys.readouterr().out
|
|
242
|
+
assert output.startswith("Review this pull request: https://github.com/example/repo/pull/1\n")
|
|
243
|
+
assert "<PR link>" not in output
|
|
244
|
+
assert "Do not use the branch name, commit message, labels, current title" in output
|
|
245
|
+
assert "gh pr edit https://github.com/example/repo/pull/1" in output
|
|
246
|
+
assert output.rstrip().endswith("Report the saved result.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@pytest.mark.parametrize("exception", [FileNotFoundError(), subprocess.TimeoutExpired("gh", 5)])
|
|
250
|
+
def test_pull_request_reminder_ignores_missing_gh_and_timeout(
|
|
251
|
+
tmp_path: Path,
|
|
252
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
253
|
+
capsys: pytest.CaptureFixture[str],
|
|
254
|
+
exception: Exception,
|
|
255
|
+
) -> None:
|
|
256
|
+
def raise_exception(*args: object, **kwargs: object) -> None:
|
|
257
|
+
raise exception
|
|
258
|
+
|
|
259
|
+
monkeypatch.setattr(post_commit.subprocess, "run", raise_exception)
|
|
260
|
+
|
|
261
|
+
post_commit.print_pull_request_reminder(tmp_path)
|
|
262
|
+
|
|
263
|
+
assert capsys.readouterr().out == ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_main_runs_pull_request_reminder_after_parent_update(
|
|
267
|
+
tmp_path: Path,
|
|
268
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
269
|
+
) -> None:
|
|
270
|
+
events: list[str] = []
|
|
271
|
+
repo = tmp_path / "repo"
|
|
272
|
+
parent = tmp_path / "parent"
|
|
273
|
+
repo.mkdir()
|
|
274
|
+
parent.mkdir()
|
|
275
|
+
monkeypatch.setattr(post_commit, "run_git_from_current_directory", lambda *args: str(repo))
|
|
276
|
+
monkeypatch.setattr(post_commit, "find_parent_repo", lambda repo_dir: parent)
|
|
277
|
+
monkeypatch.setattr(post_commit, "run_git", lambda *args, cwd: "a" * 40)
|
|
278
|
+
monkeypatch.setattr(
|
|
279
|
+
post_commit,
|
|
280
|
+
"update_parent_pointer",
|
|
281
|
+
lambda *args: events.append("parent") or (post_commit.ParentPointerStatus.UPDATED, ""),
|
|
282
|
+
)
|
|
283
|
+
monkeypatch.setattr(
|
|
284
|
+
post_commit,
|
|
285
|
+
"print_pull_request_reminder",
|
|
286
|
+
lambda repo_dir: events.append("reminder"),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
assert post_commit.main() == 0
|
|
290
|
+
assert events == ["parent", "reminder"]
|
|
@@ -148,16 +148,23 @@ def inject_session_start_context(
|
|
|
148
148
|
)
|
|
149
149
|
|
|
150
150
|
|
|
151
|
-
def build_additional_context_payload(
|
|
151
|
+
def build_additional_context_payload(
|
|
152
|
+
injection_result: InjectionResult,
|
|
153
|
+
) -> dict[str, object]:
|
|
152
154
|
"""Return the SessionStart stdout object when context was injected.
|
|
153
155
|
|
|
154
156
|
Args:
|
|
155
157
|
injection_result: Result from inject_session_start_context.
|
|
156
158
|
|
|
157
159
|
Returns:
|
|
158
|
-
``{"
|
|
160
|
+
``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
|
|
161
|
+
when injected, else an empty dict.
|
|
159
162
|
"""
|
|
160
163
|
if not injection_result.is_context_injected:
|
|
161
164
|
return {}
|
|
162
|
-
return {
|
|
163
|
-
|
|
165
|
+
return {
|
|
166
|
+
"hookSpecificOutput": {
|
|
167
|
+
"hookEventName": "SessionStart",
|
|
168
|
+
"additionalContext": injection_result.additional_context,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -143,7 +143,10 @@ def test_build_additional_context_payload_only_when_injected() -> None:
|
|
|
143
143
|
is_context_injected=False,
|
|
144
144
|
)
|
|
145
145
|
assert build_additional_context_payload(injected) == {
|
|
146
|
-
"
|
|
146
|
+
"hookSpecificOutput": {
|
|
147
|
+
"hookEventName": "SessionStart",
|
|
148
|
+
"additionalContext": "hello",
|
|
149
|
+
}
|
|
147
150
|
}
|
|
148
151
|
assert build_additional_context_payload(empty) == {}
|
|
149
152
|
|
|
@@ -165,4 +168,3 @@ def test_default_injector_configuration_reads_env(
|
|
|
165
168
|
monkeypatch.setenv(SESSION_START_INJECTOR_ENABLED_ENV_VAR, "off")
|
|
166
169
|
configuration = default_injector_configuration()
|
|
167
170
|
assert configuration.is_enabled is False
|
|
168
|
-
|
|
@@ -93,7 +93,7 @@ def run_issue_tracker_session_starter(
|
|
|
93
93
|
is_enabled: bool,
|
|
94
94
|
is_repository_eligible: bool,
|
|
95
95
|
timeout_milliseconds: int,
|
|
96
|
-
) -> dict[str,
|
|
96
|
+
) -> dict[str, object]:
|
|
97
97
|
"""Return additionalContext when opt-in and repo gate both pass, else empty.
|
|
98
98
|
|
|
99
99
|
Args:
|
|
@@ -103,7 +103,8 @@ def run_issue_tracker_session_starter(
|
|
|
103
103
|
timeout_milliseconds: Injector timeout budget.
|
|
104
104
|
|
|
105
105
|
Returns:
|
|
106
|
-
``{"
|
|
106
|
+
``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
|
|
107
|
+
when injected, else ``{}``.
|
|
107
108
|
"""
|
|
108
109
|
if not is_enabled or not is_repository_eligible:
|
|
109
110
|
return {}
|
|
@@ -68,7 +68,7 @@ def run_orchestrator_auto_starter(
|
|
|
68
68
|
payload_by_key: dict[str, object],
|
|
69
69
|
is_enabled: bool,
|
|
70
70
|
timeout_milliseconds: int,
|
|
71
|
-
) -> dict[str,
|
|
71
|
+
) -> dict[str, object]:
|
|
72
72
|
"""Return additionalContext payload when opt-in injects, else empty dict.
|
|
73
73
|
|
|
74
74
|
Args:
|
|
@@ -77,7 +77,8 @@ def run_orchestrator_auto_starter(
|
|
|
77
77
|
timeout_milliseconds: Injector timeout budget.
|
|
78
78
|
|
|
79
79
|
Returns:
|
|
80
|
-
``{"
|
|
80
|
+
``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
|
|
81
|
+
when injected, else ``{}``.
|
|
81
82
|
"""
|
|
82
83
|
if not is_enabled:
|
|
83
84
|
return {}
|
|
@@ -29,7 +29,16 @@ def build_session_directive() -> str:
|
|
|
29
29
|
|
|
30
30
|
def main() -> None:
|
|
31
31
|
"""Emit the task-list loop directive as SessionStart additionalContext."""
|
|
32
|
-
print(
|
|
32
|
+
print(
|
|
33
|
+
json.dumps(
|
|
34
|
+
{
|
|
35
|
+
"hookSpecificOutput": {
|
|
36
|
+
"hookEventName": "SessionStart",
|
|
37
|
+
"additionalContext": build_session_directive(),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
)
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
if __name__ == "__main__":
|
|
@@ -60,7 +60,9 @@ def test_enabled_eligible_startup_emits_directive() -> None:
|
|
|
60
60
|
payload = run_issue_tracker_session_starter(
|
|
61
61
|
{"source": "startup"}, True, True, DEFAULT_TIMEOUT
|
|
62
62
|
)
|
|
63
|
-
|
|
63
|
+
hook_output = payload["hookSpecificOutput"]
|
|
64
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
65
|
+
assert hook_output["additionalContext"] == ISSUE_TRACKER_SESSION_START_DIRECTIVE
|
|
64
66
|
|
|
65
67
|
|
|
66
68
|
def test_timeout_zero_emits_nothing() -> None:
|
|
@@ -51,13 +51,15 @@ def test_enabled_startup_emits_orchestrator_directive() -> None:
|
|
|
51
51
|
payload = run_orchestrator_auto_starter(
|
|
52
52
|
{"source": "startup"}, True, DEFAULT_TIMEOUT
|
|
53
53
|
)
|
|
54
|
-
|
|
54
|
+
hook_output = payload["hookSpecificOutput"]
|
|
55
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
56
|
+
assert hook_output["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
@pytest.mark.parametrize("source", ("startup", "resume", "clear", "compact"))
|
|
58
60
|
def test_enabled_known_sources_emit_directive(source: str) -> None:
|
|
59
61
|
payload = run_orchestrator_auto_starter({"source": source}, True, DEFAULT_TIMEOUT)
|
|
60
|
-
assert ORCHESTRATOR_SESSION_START_DIRECTIVE in payload
|
|
62
|
+
assert ORCHESTRATOR_SESSION_START_DIRECTIVE in payload["hookSpecificOutput"]["additionalContext"]
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def test_enabled_unknown_source_emits_nothing() -> None:
|
|
@@ -81,7 +83,7 @@ def test_main_stdin_enabled_prints_json() -> None:
|
|
|
81
83
|
)
|
|
82
84
|
assert completed.returncode == 0
|
|
83
85
|
parsed = json.loads(completed.stdout)
|
|
84
|
-
assert parsed["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
|
|
86
|
+
assert parsed["hookSpecificOutput"]["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
|
|
85
87
|
|
|
86
88
|
|
|
87
89
|
def test_main_stdin_disabled_prints_nothing() -> None:
|
|
@@ -31,31 +31,33 @@ def _run_main() -> str:
|
|
|
31
31
|
class TestSessionDirective:
|
|
32
32
|
def test_main_emits_additional_context(self) -> None:
|
|
33
33
|
emitted = json.loads(_run_main())
|
|
34
|
-
|
|
34
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
35
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
36
|
+
assert "additionalContext" in hook_output
|
|
35
37
|
|
|
36
38
|
def test_directive_carries_the_one_line_instruction(self) -> None:
|
|
37
39
|
emitted = json.loads(_run_main())
|
|
38
|
-
assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["additionalContext"]
|
|
40
|
+
assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["hookSpecificOutput"]["additionalContext"]
|
|
39
41
|
|
|
40
42
|
def test_directive_names_the_ten_minute_cadence(self) -> None:
|
|
41
43
|
emitted = json.loads(_run_main())
|
|
42
|
-
assert "10-minute" in emitted["additionalContext"]
|
|
44
|
+
assert "10-minute" in emitted["hookSpecificOutput"]["additionalContext"]
|
|
43
45
|
|
|
44
46
|
def test_directive_names_the_loop_command(self) -> None:
|
|
45
47
|
emitted = json.loads(_run_main())
|
|
46
|
-
assert "/loop 10m" in emitted["additionalContext"]
|
|
48
|
+
assert "/loop 10m" in emitted["hookSpecificOutput"]["additionalContext"]
|
|
47
49
|
|
|
48
50
|
def test_directive_runs_the_instruction_immediately(self) -> None:
|
|
49
51
|
emitted = json.loads(_run_main())
|
|
50
52
|
assert "Run that instruction once immediately when starting the loop, then" in emitted[
|
|
51
|
-
"
|
|
52
|
-
]
|
|
53
|
+
"hookSpecificOutput"
|
|
54
|
+
]["additionalContext"]
|
|
53
55
|
|
|
54
56
|
def test_directive_reuses_an_existing_loop(self) -> None:
|
|
55
57
|
emitted = json.loads(_run_main())
|
|
56
58
|
assert "Reuse the active task-list maintenance loop when available." in emitted[
|
|
57
|
-
"
|
|
58
|
-
]
|
|
59
|
+
"hookSpecificOutput"
|
|
60
|
+
]["additionalContext"]
|
|
59
61
|
|
|
60
62
|
def test_build_session_directive_returns_the_shared_constant(self) -> None:
|
|
61
63
|
assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE
|
|
@@ -99,7 +99,9 @@ class TestUntrackedRepoDetection:
|
|
|
99
99
|
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
100
100
|
assert stdout.strip() != ""
|
|
101
101
|
emitted = json.loads(stdout)
|
|
102
|
-
|
|
102
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
103
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
104
|
+
assert "additionalContext" in hook_output
|
|
103
105
|
|
|
104
106
|
def test_emitted_context_names_the_detected_repo_path(self, tmp_path: Path) -> None:
|
|
105
107
|
repo_root = tmp_path / "new-repo"
|
|
@@ -107,7 +109,7 @@ class TestUntrackedRepoDetection:
|
|
|
107
109
|
(repo_root / ".git").mkdir()
|
|
108
110
|
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
109
111
|
emitted = json.loads(stdout)
|
|
110
|
-
context_text = emitted["additionalContext"]
|
|
112
|
+
context_text = emitted["hookSpecificOutput"]["additionalContext"]
|
|
111
113
|
assert str(repo_root) in context_text
|
|
112
114
|
|
|
113
115
|
def test_emitted_context_names_the_config_file_path(self, tmp_path: Path) -> None:
|
|
@@ -116,7 +118,7 @@ class TestUntrackedRepoDetection:
|
|
|
116
118
|
(repo_root / ".git").mkdir()
|
|
117
119
|
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
118
120
|
emitted = json.loads(stdout)
|
|
119
|
-
context_text = emitted["additionalContext"]
|
|
121
|
+
context_text = emitted["hookSpecificOutput"]["additionalContext"]
|
|
120
122
|
assert "project-paths.json" in context_text
|
|
121
123
|
|
|
122
124
|
def test_emitted_context_instructs_claude_to_use_ask_user_question(
|
|
@@ -127,7 +129,7 @@ class TestUntrackedRepoDetection:
|
|
|
127
129
|
(repo_root / ".git").mkdir()
|
|
128
130
|
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
129
131
|
emitted = json.loads(stdout)
|
|
130
|
-
context_text = emitted["additionalContext"]
|
|
132
|
+
context_text = emitted["hookSpecificOutput"]["additionalContext"]
|
|
131
133
|
assert "AskUserQuestion" in context_text
|
|
132
134
|
|
|
133
135
|
def test_emitted_context_states_hook_has_written_nothing(
|
|
@@ -138,7 +140,7 @@ class TestUntrackedRepoDetection:
|
|
|
138
140
|
(repo_root / ".git").mkdir()
|
|
139
141
|
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
140
142
|
emitted = json.loads(stdout)
|
|
141
|
-
context_text = emitted["additionalContext"]
|
|
143
|
+
context_text = emitted["hookSpecificOutput"]["additionalContext"]
|
|
142
144
|
assert (
|
|
143
145
|
"written nothing" in context_text.lower()
|
|
144
146
|
or "has not written" in context_text.lower()
|
|
@@ -30,15 +30,17 @@ def _run_main() -> str:
|
|
|
30
30
|
class TestWorkingStylePrompt:
|
|
31
31
|
def test_main_emits_additional_context(self) -> None:
|
|
32
32
|
emitted = json.loads(_run_main())
|
|
33
|
-
|
|
33
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
34
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
35
|
+
assert "additionalContext" in hook_output
|
|
34
36
|
|
|
35
37
|
def test_additional_context_matches_prompt_exactly(self) -> None:
|
|
36
38
|
emitted = json.loads(_run_main())
|
|
37
|
-
assert emitted["additionalContext"] == WORKING_STYLE_PROMPT
|
|
39
|
+
assert emitted["hookSpecificOutput"]["additionalContext"] == WORKING_STYLE_PROMPT
|
|
38
40
|
|
|
39
41
|
def test_emitted_prompt_contains_canonical_policy_and_scope_guidance(self) -> None:
|
|
40
42
|
emitted = json.loads(_run_main())
|
|
41
|
-
prompt_text = emitted["additionalContext"]
|
|
43
|
+
prompt_text = emitted["hookSpecificOutput"]["additionalContext"]
|
|
42
44
|
assert "Document each task in a location that remains easy to find later." in prompt_text
|
|
43
45
|
assert "Deliver the requested work at its intended scope." in prompt_text
|
|
44
46
|
assert "Use ELI5 for beginner framing, large visuals, minimal text" in prompt_text
|
|
@@ -64,7 +64,16 @@ def main() -> None:
|
|
|
64
64
|
if registry_contains_path(known_registry, git_root):
|
|
65
65
|
sys.exit(0)
|
|
66
66
|
instruction = _build_confirm_instruction(git_root)
|
|
67
|
-
print(
|
|
67
|
+
print(
|
|
68
|
+
json.dumps(
|
|
69
|
+
{
|
|
70
|
+
"hookSpecificOutput": {
|
|
71
|
+
"hookEventName": "SessionStart",
|
|
72
|
+
"additionalContext": instruction,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
)
|
|
68
77
|
except Exception as e:
|
|
69
78
|
_logger.error("%s", e)
|
|
70
79
|
sys.exit(0)
|
|
@@ -31,7 +31,16 @@ def build_session_directive() -> str:
|
|
|
31
31
|
|
|
32
32
|
def main() -> None:
|
|
33
33
|
"""Emit the working-style prompt as SessionStart additionalContext."""
|
|
34
|
-
print(
|
|
34
|
+
print(
|
|
35
|
+
json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"hookSpecificOutput": {
|
|
38
|
+
"hookEventName": "SessionStart",
|
|
39
|
+
"additionalContext": build_session_directive(),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
)
|
|
35
44
|
|
|
36
45
|
|
|
37
46
|
if __name__ == "__main__":
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ reparse_point_attribute_name = "FILE_ATTRIBUTE_REPARSE_POINT"
|
|
|
25
25
|
manifest_indentation_width = 2
|
|
26
26
|
publish_plan_max_positional_arguments = 3
|
|
27
27
|
publish_plan_failure_injector_position = 2
|
|
28
|
-
frontmatter_unsupported_fields = ("tools", "model", "color")
|
|
28
|
+
frontmatter_unsupported_fields = ("tools", "model", "color", "disable-model-invocation")
|
|
29
29
|
instruction_alias_filenames = frozenset({"AGENTS.md", "CLAUDE.md"})
|
|
30
30
|
failure_blast_radius_rule_relative_path = "rules/failure-blast-radius.md"
|
|
31
31
|
codex_instruction_target_path = "AGENTS.md"
|
|
@@ -152,7 +152,9 @@ report_categories = (
|
|
|
152
152
|
"stale_managed", "deleted", "unsupported", "conflicted", "errors",
|
|
153
153
|
)
|
|
154
154
|
report_categories_public_name = "REPORT_CATEGORIES"
|
|
155
|
-
frontmatter_allowed_fields = {
|
|
155
|
+
frontmatter_allowed_fields = {
|
|
156
|
+
"name", "description", "tools", "model", "color", "disable-model-invocation"
|
|
157
|
+
}
|
|
156
158
|
line_separator = "\n"
|
|
157
159
|
comma_separator = ", "
|
|
158
160
|
|
|
@@ -40,8 +40,8 @@ def test_conversion_escapes_toml_content() -> None:
|
|
|
40
40
|
def test_malformed_and_unsupported_frontmatter() -> None:
|
|
41
41
|
with pytest.raises(MaterializerError):
|
|
42
42
|
parse_frontmatter(Path("bad.md"), "---\nname: x\n", "bad.md")
|
|
43
|
-
agent = parse_frontmatter(Path("ok.md"), "---\nname: x\ndescription: y\nmodel: sonnet\ncolor: blue\n---\n", "ok.md")
|
|
44
|
-
assert agent.unsupported == ("color", "model")
|
|
43
|
+
agent = parse_frontmatter(Path("ok.md"), "---\nname: x\ndescription: y\nmodel: sonnet\ncolor: blue\ndisable-model-invocation: true\n---\n", "ok.md")
|
|
44
|
+
assert agent.unsupported == ("color", "disable-model-invocation", "model")
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
@pytest.mark.parametrize(
|