claude-dev-env 4.1.0 → 4.3.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 (89) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/everything-search/SKILL.md +18 -3
  6. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  7. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  8. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  9. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  10. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  11. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  12. package/.agents/skills/pull-request/SKILL.md +218 -0
  13. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  14. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  15. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  16. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  17. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  18. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  19. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  20. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  21. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  22. package/.agents/skills/test-runner/SKILL.md +69 -0
  23. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  24. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  25. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  26. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  27. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  28. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  29. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  30. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  31. package/bin/ever-shipped-skills.mjs +2 -0
  32. package/bin/ever-shipped-skills.test.mjs +8 -0
  33. package/bin/install.mjs +9 -1
  34. package/bin/install.prune.test.mjs +149 -0
  35. package/bin/install.test.mjs +24 -7
  36. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  37. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  40. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  41. package/hooks/gh_post_body_texts.py +195 -0
  42. package/hooks/hooks.json +0 -15
  43. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
  45. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  46. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  47. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
  48. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  49. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  50. package/hooks/observability/test_failure_recorder.py +1 -1
  51. package/hooks/pyproject.toml +1 -1
  52. package/hooks/test_gh_post_body_texts.py +79 -0
  53. package/package.json +1 -1
  54. package/rules/durable-post-artifacts.md +31 -20
  55. package/rules/filesystem-search.md +5 -5
  56. package/rules/gh-cli-conventions.md +23 -13
  57. package/rules/git-workflow.md +21 -8
  58. package/scripts/policy_lint/adapter_configuration.py +53 -12
  59. package/scripts/repository_checks/config/constants.py +0 -5
  60. package/scripts/tests/test_adapter_configuration.py +54 -0
  61. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  62. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  63. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  64. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  65. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  66. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  67. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  68. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  69. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  70. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  71. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  72. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  73. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  74. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  75. package/hooks/blocking/test_preflight_check.py +0 -343
  76. package/hooks/blocking/test_test_preflight_check.py +0 -80
  77. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  78. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  79. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  80. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  81. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  82. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  83. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
  84. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  85. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  86. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  87. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  88. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  89. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -0,0 +1,19 @@
1
+ """Tests for GitHub post body extraction constants."""
2
+
3
+ from hooks_constants import gh_post_body_texts_constants as constants
4
+
5
+
6
+ def test_post_actions_cover_pull_requests_and_issues() -> None:
7
+ assert constants.ALL_GH_POST_SUBCOMMANDS == {
8
+ "issue": frozenset({"comment", "create", "edit"}),
9
+ "pr": frozenset({"comment", "create", "edit", "review"}),
10
+ }
11
+ assert constants.ALL_MCP_BODY_PARAM_NAMES == ("body", "comment")
12
+
13
+
14
+ def test_parser_constants_are_stable() -> None:
15
+ assert constants.BODY_FILE_ENCODING == "utf-8"
16
+ assert constants.BODY_FLAG_WITH_VALUE_STEP == 2
17
+ assert constants.GH_COMMAND_NAME == "gh"
18
+ assert constants.MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT == 2
19
+ assert constants.TOKEN_JOIN_SEPARATOR == " "
@@ -101,13 +101,13 @@ try:
101
101
  for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
102
102
  if each_bootstrap_directory not in sys.path:
103
103
  sys.path.insert(0, each_bootstrap_directory)
104
- from hooks_constants.gh_pr_author_swap_constants import BASH_TOOL_NAME
105
104
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
106
105
  from hooks_constants.test_failure_recorder_constants import (
107
106
  ALL_CHAINING_OPERATOR_SUBSTRINGS,
108
107
  ALL_PYTEST_MENTION_SUBSTRINGS,
109
108
  ALL_PYTEST_PROGRAM_BASENAMES,
110
109
  ALL_REDIRECTION_TOKENS,
110
+ BASH_TOOL_NAME,
111
111
  EXIT_CODE_ERROR_PREFIX,
112
112
  MODULE_RUN_FLAG,
113
113
  NODE_ID_FILE_PATH_SEPARATOR,
@@ -24,7 +24,7 @@ exclude = [
24
24
 
25
25
 
26
26
  [[tool.mypy.overrides]]
27
- module = ["exempt_paths", "code_rules_path_utils", "gh_pr_author_enforcer"]
27
+ module = ["exempt_paths", "code_rules_path_utils"]
28
28
  warn_unused_ignores = false
29
29
 
30
30
  [[tool.mypy.overrides]]
@@ -0,0 +1,79 @@
1
+ """Tests for durable GitHub post body extraction."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from gh_post_body_texts import (
8
+ extract_gh_post_body_texts_for_privacy_gate,
9
+ extract_mcp_body_texts,
10
+ )
11
+
12
+
13
+ @pytest.mark.parametrize(
14
+ ("command", "expected_text"),
15
+ [
16
+ ('gh pr create --body "pull request text"', "pull request text"),
17
+ ('gh pr comment 12 --body="comment text"', "comment text"),
18
+ ('gh issue edit 7 -b "issue text"', "issue text"),
19
+ ('GH_HOST=github.example gh pr review 8 --body "review text"', "review text"),
20
+ ],
21
+ )
22
+ def test_inline_post_bodies_are_extracted(command: str, expected_text: str) -> None:
23
+ all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(command)
24
+ assert all_body_texts == [expected_text]
25
+ assert deny_reason is None
26
+
27
+
28
+ def test_relative_body_file_uses_supplied_working_directory(tmp_path: Path) -> None:
29
+ body_file = tmp_path / "body.md"
30
+ body_file.write_text("body file text", encoding="utf-8")
31
+ all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(
32
+ "gh pr comment 12 --body-file body.md",
33
+ str(tmp_path),
34
+ )
35
+ assert all_body_texts == ["body file text"]
36
+ assert deny_reason is None
37
+
38
+
39
+ @pytest.mark.parametrize("body_file_text", ["missing.md", "$BODY_FILE", "-"])
40
+ def test_unreadable_body_file_fails_closed(body_file_text: str) -> None:
41
+ all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(
42
+ f"gh pr comment 12 --body-file {body_file_text}"
43
+ )
44
+ assert all_body_texts == []
45
+ assert deny_reason is not None
46
+ assert "could not be read" in deny_reason
47
+
48
+
49
+ def test_invalid_utf8_body_file_fails_closed(tmp_path: Path) -> None:
50
+ body_file = tmp_path / "body.md"
51
+ body_file.write_bytes(b"\xff")
52
+ all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(
53
+ f'gh issue create --body-file "{body_file}"'
54
+ )
55
+ assert all_body_texts == []
56
+ assert deny_reason is not None
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "command",
61
+ [
62
+ "gh pr view 12",
63
+ 'echo "gh pr comment 12 --body text"',
64
+ "git status",
65
+ "",
66
+ ],
67
+ )
68
+ def test_non_post_commands_have_no_body(command: str) -> None:
69
+ assert extract_gh_post_body_texts_for_privacy_gate(command) == ([], None)
70
+
71
+
72
+ def test_mcp_body_and_comment_values_are_extracted() -> None:
73
+ assert extract_mcp_body_texts(
74
+ {"body": "body text", "comment": "comment text", "title": "ignored"}
75
+ ) == ["body text", "comment text"]
76
+
77
+
78
+ def test_mcp_non_string_and_empty_values_are_ignored() -> None:
79
+ assert extract_mcp_body_texts({"body": "", "comment": 7}) == []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,38 +1,49 @@
1
- # Durable Post Artifacts
1
+ # GitHub post input rules
2
2
 
3
- **When this applies:** Any GitHub post that lives on the server — an issue, a pull request, a comment, or a review — created through `gh` (`gh pr create/comment/edit/review`, `gh issue create/comment/edit`) or a GitHub MCP post tool.
3
+ ## When this applies
4
+
5
+ Use this rule for a GitHub issue, pull request, comment, or review created
6
+ through `gh` or a GitHub MCP post tool.
4
7
 
5
8
  ## Rule
6
9
 
7
- A post lives forever. Job scratch directories, worktrees, and system temp folders do not — they are cleaned soon after the run that made them. So a post must never point at a path in that scratch. The moment the directory is cleaned, the reference breaks and the reader is left with a dead path.
10
+ A post remains after the job ends. Job scratch directories, worktrees, and
11
+ system temp folders do not. Do not put a path from one of those directories in
12
+ a post.
8
13
 
9
- Handle the two kinds of content differently:
14
+ Handle text and binary content differently:
10
15
 
11
- - **Text data** (logs, tables, diffs, stack traces): paste the actual text inline in the post body. Do not link a scratch file that holds it.
12
- - **Binary artifacts** (images, screenshots, archives): upload the file to the repository's durable `artifacts` release and link the permanent URL. Use the helper:
16
+ - **Text data** such as logs, tables, diffs, and stack traces belongs inline in
17
+ the post body. Do not link a scratch file that holds text data.
18
+ - **Binary artifacts** such as images, screenshots, and archives belong in the
19
+ repository's durable `artifacts` release. Use the helper:
13
20
 
14
21
  ```
15
22
  python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>
16
23
  ```
17
24
 
18
- It ensures the repository has a prerelease tagged `artifacts`, uploads the file under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints the permanent download URL. Put that URL in the post.
25
+ The helper creates the `artifacts` prerelease when needed, uploads the file
26
+ under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints a permanent download
27
+ URL. Put that URL in the post.
19
28
 
20
29
  ## Volatile paths that must not appear in a post body
21
30
 
22
- - A job scratch directory (`.claude-profile-a/jobs/`)
23
- - A worktree (`.claude/worktrees/`)
24
- - A system temp location (`AppData\Local\Temp`, `%TEMP%`, `$env:TEMP`, `/tmp/`)
25
- - The job scratch environment variable (`$CLAUDE_JOB_DIR`)
26
-
27
- Both slash directions count.
28
-
29
- The worktree and job-scratch entries count as a path when either:
31
+ - A job scratch directory: `.claude-profile-a/jobs/`
32
+ - A worktree: `.claude/worktrees/`
33
+ - A system temp location: `AppData\\Local\\Temp`, `%TEMP%`, `$env:TEMP`, or
34
+ `/tmp/`
35
+ - The job scratch environment variable: `$CLAUDE_JOB_DIR`
30
36
 
31
- - A `/` or `\` sits right before them — a drive-letter path (`C:\Users\me\.claude\worktrees\wt\f.py`), a home path (`~/.claude/worktrees/wt`), or a POSIX absolute path (`/home/me/.claude-profile-a/jobs/j/log.txt`).
32
- - A path segment follows them — a relative path that names a child under that directory (`see .claude/worktrees/wt-1/notes.md`, a markdown link target, or `cd .claude/worktrees/wt-199`).
37
+ Both slash directions count. The path rule applies when a slash or backslash
38
+ precedes a marker, or when a path segment follows it. A standalone directory
39
+ name does not form a path.
33
40
 
34
- With neither anchor the text names the directory rather than something inside it, and it posts — a quoted config constant, a backticked directory name, or a placeholder form such as `.claude/worktrees/<name>`. Un-backticked prose that puts a word immediately after the marker reads the same as a relative path and is blocked; the placeholder form is the documented escape.
41
+ ## Validation
35
42
 
36
- ## Enforcement
43
+ Run `.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py` before the
44
+ server write. Pass the matching action and body file. Use `pr-create`,
45
+ `pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
46
+ `issue-comment`, or `github-mcp-post`.
37
47
 
38
- The `volatile_path_in_post_blocker` PreToolUse hook reads the body of each `gh` post command and each GitHub MCP post call, scans it for these markers, and blocks the post when it finds one. For a `--body-file`, the hook reads the file and scans its contents, so writing the body to a temp file and passing it with `--body-file` stays allowed — what the hook rejects is a volatile path inside the text that gets posted.
48
+ The linter reads the body file and reports a volatile local path without
49
+ printing the body. Fix the body and rerun the linter before posting.
@@ -1,6 +1,6 @@
1
1
  # Filesystem Search
2
2
 
3
- **When this applies:** Any search for files by name, path, extension, size, or date — through `es.exe`, a shell `find`, a recursive `Get-ChildItem` / `gci` / `dir` / `ls -R`, or the harness Grep and Glob tools.
3
+ **When this applies:** Any search for files by name, path, extension, size, or date. That includes `es.exe`, a shell `find`, a recursive `Get-ChildItem` / `gci` / `dir` / `ls -R`, or the harness Grep and Glob tools.
4
4
 
5
5
  ## The scope invariant
6
6
 
@@ -14,7 +14,7 @@ Three tools are equally sanctioned; pick by what you know:
14
14
 
15
15
  | You know | Use |
16
16
  |---|---|
17
- | The exact path | `Read` — no search at all |
17
+ | The exact path | `Read`. Do not search. |
18
18
  | A name, extension, or date, on Windows | `es.exe` with a path scope |
19
19
  | A name or path pattern | The harness `Glob` tool |
20
20
  | Text inside files | The harness `Grep` tool |
@@ -39,13 +39,13 @@ When `es.exe` fails or returns nothing, fall back to `Glob` or `Grep` without pa
39
39
  | Git Bash drive root | `find /c -name '*.py'` |
40
40
  | Windows drive root | `find C:\ -name foo` / `Get-ChildItem C:\ -Recurse` |
41
41
  | Bare home | `find ~ -name README.md` / `find $HOME -type f` |
42
- | Network share root | `find //server/share -name x` — a path under the share (`//server/share/project/src`) is allowed |
42
+ | Network share root | `find //server/share -name x`. A path under the share (`//server/share/project/src`) is allowed |
43
43
 
44
44
  ## Shell batching
45
45
 
46
46
  Issue one shell search at a time when the walk is large. Parallel full-tree searches contend for the shell and can lock the host. Harness `Grep` and `Glob` calls carry no such cost and run in parallel freely.
47
47
 
48
- ## Enforcement
48
+ ## Search controls
49
49
 
50
50
  - `unscoped_search_blocker` (PreToolUse on Bash and PowerShell, hosted by `bash_pre_tool_use_dispatcher`) denies a walk from an unscoped root and returns the scoped alternative.
51
- - `es_exe_path_rewriter` (PreToolUse on Bash) substitutes `{project-name}` placeholders and bare registry keys in an `es.exe` command with their quoted absolute paths, read from `~/.claude/project-paths.json`. It allows and rewrites; it never blocks, and a machine with no registry file passes the command through unchanged. `scripts/setup_project_paths.py` writes the registry.
51
+ - For Everything searches that use a project name, run `python "${CLAUDE_SKILL_DIR}/scripts/everything_search.py" <project-name> <search arguments>`. An exact project name becomes its path from `~/.claude/project-paths.json`. The command then starts `es.exe` without a shell. `scripts/setup_project_paths.py` writes the registry.
@@ -1,26 +1,36 @@
1
- # gh CLI Conventions
1
+ # gh CLI conventions
2
2
 
3
- Two call shapes the `gh` CLI gets wrong by default.
3
+ Two `gh` call shapes need explicit handling.
4
4
 
5
- ## Body content goes in a file
5
+ ## Put body content in a file
6
6
 
7
- Every `gh` command carrying markdown body content (`gh pr create/edit/comment/review`, `gh issue create/edit/comment`) uses `--body-file <path>` with a temp file — never a `--body` / `-b` string, where backticks land on GitHub as a literal `` \` ``. Write the temp file BOM-free:
7
+ Every `gh` command that carries markdown body content uses
8
+ `--body-file <path>`. This applies to `gh pr create`, `gh pr edit`,
9
+ `gh pr comment`, `gh pr review`, `gh issue create`, `gh issue edit`, and
10
+ `gh issue comment`. Never pass a `--body` or `-b` string. Write the file as
11
+ BOM-free UTF-8:
8
12
 
9
13
  ```powershell
10
14
  [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))
11
15
  ```
12
16
 
13
- MCP GitHub tools take `body` as a structured parameter and are unaffected.
17
+ MCP GitHub tools take `body` as a structured parameter. Write the same body to
18
+ a UTF-8 file and run the shared linter before sending that parameter.
14
19
 
15
- `gh_body_arg_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) blocks `--body <arg>` and returns the corrective message.
20
+ For pull requests, use
21
+ `.agents/skills/pull-request/scripts/pull_request.py`. It passes
22
+ `--body-file` to `gh` after the action-aware linter succeeds. For issues and
23
+ GitHub MCP posts, run the linter directly with `issue-create`, `issue-edit`,
24
+ `issue-comment`, or `github-mcp-post` as the action.
16
25
 
17
26
  ## Paginated reads slurp before they filter
18
27
 
19
- Every `gh api` read of a paginated GitHub list endpoint (PR `reviews` / `comments` / `files`, issue `comments`, `pulls`, `issues`) uses `--paginate --slurp` piped to **external** `jq`. The built-in `--jq` runs per page, so a cross-page operation like `sort_by | last` gives a wrong-but-confident result.
28
+ Every `gh api` read of a paginated GitHub list endpoint uses
29
+ `--paginate --slurp` and pipes the result to external `jq`. This applies to PR
30
+ reviews, comments, and files, plus issue comments, pulls, and issues. The
31
+ built-in `--jq` runs once per page and can produce a wrong cross-page result.
20
32
 
21
- Single-object endpoints (`pulls/<n>`, `issues/<n>`) skip pagination and may use `--jq` directly. For a newest-first walk, sort the slurped array and take the last element; for single-page bounds, cap with a `per_page` query parameter.
22
-
23
- ## Sibling rules
24
-
25
- - [`destructive-commands.md`](destructive-commands.md) — why a body describing `rm -rf` must travel by file path.
26
- - [`durable-post-artifacts.md`](durable-post-artifacts.md) — what a post body may reference.
33
+ Single-object endpoints such as `pulls/<n>` and `issues/<n>` do not need
34
+ pagination and may use `--jq` directly. For a newest-first walk, sort the
35
+ slurped array and take the last element. For one page, cap the request with a
36
+ `per_page` query parameter.
@@ -1,8 +1,8 @@
1
- # Git Workflow
1
+ # Git workflow
2
2
 
3
- User-level rule: applies to **every** git repo that uses GitHub with `gh` (no exceptions for “small” or non-primary repos unless the user says otherwise in the session).
3
+ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small or non-primary repos follow the same rule unless the user says otherwise in the session.
4
4
 
5
- ## Workflow Decision Tree
5
+ ## Workflow decision tree
6
6
 
7
7
  **When to use stacked PRs:** Feature B depends on Feature A's implementation
8
8
 
@@ -13,19 +13,32 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh` (no ex
13
13
  2. Get reviewed and MERGE infrastructure first
14
14
  3. Launch parallel feature PRs that use merged infrastructure
15
15
 
16
- ## PR Submission Rules
16
+ ## Pull request submission rules
17
17
 
18
18
  **ALWAYS create PRs as DRAFT:** Use `gh pr create --draft` for ALL PRs
19
19
 
20
- **The pr-description-writer agent writes the body:** Spawn the `pr-description-writer` agent (Agent tool, `subagent_type` `pr-description-writer`), then publish what it returns with `--body-file`. `pr_description_writer_gate.py` (PreToolUse on Bash and PowerShell, hosted by `bash_pre_tool_use_dispatcher`) denies a `gh pr create` when the session records no such spawn. A trailing `# pr-description-skip` comment on the command opts out for a body you write yourself.
20
+ Use the `pr-description-writer` agent before creating a pull request or
21
+ rewriting its full description. Publish its title and body file through
22
+ `.agents/skills/pull-request/scripts/pull_request.py`. The
23
+ `pr-title-description` skill may review the writer's output but cannot replace
24
+ the required writer.
21
25
 
22
- ## Git Golden Rules (NON-NEGOTIABLE)
26
+ Run `_shared/pr-loop/scripts/durable_post_lint.py` before any pull request,
27
+ issue, or GitHub MCP post. The linter checks the action-specific title, body,
28
+ and volatile-path rules before credential lookup or network access.
29
+
30
+ Use `.agents/skills/pull-request/scripts/recover_legacy_author.py
31
+ <exact-state-file> --confirm-inactive` only for one explicitly selected legacy
32
+ author record. Do not infer a record from age alone. Keep every other record
33
+ untouched.
34
+
35
+ ## Git golden rules
23
36
 
24
37
  1. **DRAFT BEFORE PUSH**: When pushing ANYTHING to a PR, it MUST be in draft state first
25
38
  - Before push: `gh pr ready --undo`
26
39
  - After review approved: `gh pr ready`
27
40
 
28
- ## Never Commit Working Documents or Images
41
+ ## Never commit working documents or images
29
42
 
30
43
  **NEVER commit these files to the repo:**
31
44
 
@@ -49,4 +62,4 @@ An image a PR needs as visual evidence is not an exception to that row. Upload i
49
62
 
50
63
  Repair only reported findings, then re-verify after every repair.
51
64
 
52
- Every `gh` post in this workflow uses `--body-file` per `gh-cli-conventions.md` and keeps volatile scratch paths out per `durable-post-artifacts.md`; stage session edits per `re-stage-before-commit.md` before each commit.
65
+ Every `gh` post in this workflow uses `--body-file` per `gh-cli-conventions.md` and keeps volatile scratch paths out per `durable-post-artifacts.md`. Stage session edits per `re-stage-before-commit.md` before each commit.
@@ -23,30 +23,69 @@ def hook_configuration_diagnostics(
23
23
  adapter_support._document_path(repository_root, document)
24
24
  if document.path.name != "hooks.json":
25
25
  return ()
26
- all_messages = _hook_configuration_messages(document.text)
26
+ all_messages = _hook_configuration_messages(document.text, document.prior_text)
27
27
  return adapter_support._diagnostics_for_messages(
28
28
  document, "hook-configuration", all_messages
29
29
  )
30
30
 
31
31
 
32
- def _hook_configuration_messages(configuration_text: str) -> tuple[str, ...]:
32
+ def _parsed_configuration(
33
+ configuration_text: str,
34
+ invalid_json_message: str,
35
+ invalid_object_message: str,
36
+ ) -> dict[object, object]:
33
37
  try:
34
38
  parsed_configuration = json.loads(configuration_text)
35
- except json.JSONDecodeError:
36
- return ("Hook configuration is not valid JSON",)
39
+ except json.JSONDecodeError as error:
40
+ raise ValueError(invalid_json_message) from error
37
41
  if not isinstance(parsed_configuration, dict):
38
- return ("Hook configuration must be an object",)
39
- all_registered_strings = _registered_hook_strings(parsed_configuration)
40
- if any(
41
- _contains_action_boundary_marker(each_string)
42
- for each_string in all_registered_strings
43
- ):
42
+ raise TypeError(invalid_object_message)
43
+ return parsed_configuration
44
+
45
+
46
+ def _action_boundary_strings(
47
+ all_configuration_entries: dict[object, object],
48
+ ) -> frozenset[str]:
49
+ return frozenset(
50
+ each_string
51
+ for each_string in _registered_hook_strings(all_configuration_entries)
52
+ if _contains_action_boundary_marker(each_string)
53
+ )
54
+
55
+
56
+ def _hook_configuration_messages(
57
+ configuration_text: str,
58
+ prior_configuration_text: str | None,
59
+ ) -> tuple[str, ...]:
60
+ try:
61
+ current_configuration = _parsed_configuration(
62
+ configuration_text,
63
+ "Hook configuration is not valid JSON",
64
+ "Hook configuration must be an object",
65
+ )
66
+ all_current_action_boundary_strings = _action_boundary_strings(
67
+ current_configuration
68
+ )
69
+ if prior_configuration_text is None:
70
+ all_prior_action_boundary_strings: frozenset[str] = frozenset()
71
+ else:
72
+ prior_configuration = _parsed_configuration(
73
+ prior_configuration_text,
74
+ "Prior hook configuration is not valid JSON",
75
+ "Prior hook configuration must be an object",
76
+ )
77
+ all_prior_action_boundary_strings = _action_boundary_strings(
78
+ prior_configuration
79
+ )
80
+ except (TypeError, ValueError) as error:
81
+ return (str(error),)
82
+ if all_current_action_boundary_strings - all_prior_action_boundary_strings:
44
83
  return ("Hook configuration registers a deny, block, or ask policy path",)
45
84
  return ()
46
85
 
47
86
 
48
87
  def _registered_hook_strings(
49
- all_configuration_entries: dict[object, object]
88
+ all_configuration_entries: dict[object, object],
50
89
  ) -> tuple[str, ...]:
51
90
  all_registered_strings: list[str] = []
52
91
  for each_key, each_entry in all_configuration_entries.items():
@@ -62,7 +101,9 @@ def _registered_strings_for_entry(
62
101
  all_registered_strings: list[str] = []
63
102
  if each_key in constants.ALL_HOOK_REGISTRATION_KEYS and isinstance(each_entry, str):
64
103
  all_registered_strings.append(each_entry)
65
- if each_key in constants.ALL_DECISION_REGISTRATION_KEYS and isinstance(each_entry, str):
104
+ if each_key in constants.ALL_DECISION_REGISTRATION_KEYS and isinstance(
105
+ each_entry, str
106
+ ):
66
107
  all_registered_strings.append(each_entry)
67
108
  all_registered_strings.extend(_registered_nested_strings(each_entry))
68
109
  return tuple(all_registered_strings)
@@ -131,11 +131,6 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
131
131
  "home-path",
132
132
  "09f83c224a02c370d3d63de5c330e1a830fc92866330fc7963b5b5fd20ffcb91",
133
133
  ),
134
- (
135
- "packages/claude-dev-env/hooks/blocking/volatile_path_in_post_blocker.py",
136
- "home-path",
137
- "ed9203c8078e4c6b832600085f28f24656d679aa8dca82ecc2557afc3df25036",
138
- ),
139
134
  (
140
135
  "packages/claude-dev-env/hooks/hooks_constants/hardcoded_user_path_constants.py",
141
136
  "home-path",
@@ -0,0 +1,54 @@
1
+ """Incremental migration tests for the hook-configuration lint rule."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+
9
+ from policy_lint import adapters
10
+ from policy_lint.model import Document
11
+
12
+
13
+ def _hook_document(all_commands: list[str], prior_text: str) -> Document:
14
+ current_text = json.dumps(
15
+ {
16
+ "hooks": {
17
+ "PreToolUse": [
18
+ {
19
+ "hooks": [
20
+ {"command": each_command} for each_command in all_commands
21
+ ]
22
+ }
23
+ ]
24
+ }
25
+ }
26
+ )
27
+ return replace(
28
+ Document.from_text("hooks/hooks.json", current_text),
29
+ prior_text=prior_text,
30
+ )
31
+
32
+
33
+ def test_incremental_blocker_removal_is_clean(tmp_path: Path) -> None:
34
+ prior_text = _hook_document(
35
+ ["hooks/blocking/first.py", "hooks/blocking/second.py"],
36
+ '{"hooks": {}}',
37
+ ).text
38
+ current_document = _hook_document(
39
+ ["hooks/blocking/second.py"],
40
+ prior_text,
41
+ )
42
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
43
+
44
+
45
+ def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
46
+ current_document = _hook_document(
47
+ ["hooks/blocking/new.py"],
48
+ '{"hooks": {}}',
49
+ )
50
+ all_diagnostics = adapters.hook_configuration_diagnostics(
51
+ current_document, tmp_path
52
+ )
53
+ assert len(all_diagnostics) == 1
54
+ assert all_diagnostics[0].rule_id == "hook-configuration"