claude-dev-env 8.68.4 → 8.69.1
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/correction/SKILL.md +21 -7
- package/.agents/skills/correction/playbooks/fix.md +14 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
- package/.agents/skills/second-account-workers/SKILL.md +21 -3
- package/docs/rule-guides/features-start-with-an-eval.md +40 -0
- package/hooks/blocking/followup_pr_dedupe.py +55 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
- package/hooks/blocking/pull_request_proof.py +86 -20
- package/hooks/blocking/test_artifact_look_gate.py +1 -0
- package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
- package/hooks/blocking/test_pull_request_proof.py +124 -1
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/features/write-edit-blocking.md +3 -5
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
- package/hooks/test_transcript_skill_scan.py +75 -1
- package/hooks/transcript_skill_scan.py +87 -15
- package/package.json +1 -1
- package/rules/anti-corollary-tests.md +2 -2
- package/rules/archiving-agent-config.md +2 -2
- package/rules/asd-ste100-language.md +2 -2
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/cleanup-temp-files.md +3 -3
- package/rules/code-standards.md +1 -1
- package/rules/correction-lens.md +3 -3
- package/rules/destructive-commands.md +3 -3
- package/rules/doc-inventory-integrity.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/explore-thoroughly.md +1 -1
- package/rules/failure-blast-radius.md +1 -1
- package/rules/falsify-before-green.md +2 -2
- package/rules/features-start-with-an-eval.md +9 -0
- package/rules/filesystem-search.md +3 -3
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/long-horizon-autonomy.md +2 -2
- package/rules/memory-stores-durable-facts.md +2 -2
- package/rules/no-contrast-framing.md +1 -1
- package/rules/no-cross-skill-duplicate-helpers.md +1 -1
- package/rules/orphan-css-class.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +1 -1
- package/rules/prefer-existing-tools.md +2 -2
- package/rules/proof-before-pull-request.md +1 -1
- package/rules/request-scope.md +1 -1
- package/rules/research-mode.md +2 -2
- package/rules/retired-hook-prose.md +1 -1
- package/rules/session-title.md +1 -1
- package/rules/shell-invocation.md +2 -2
- package/rules/testing.md +1 -1
- package/rules/verify-before-asking.md +3 -3
- package/rules/verify-runtime-state.md +2 -2
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/account_broker.py +10 -3
- package/scripts/account_broker_support.py +7 -3
- package/scripts/claude_account_worker.py +24 -5
- package/scripts/claude_account_worker_process.py +5 -0
- package/scripts/claude_account_worker_report.py +16 -1
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_account_broker_support.py +24 -0
- package/scripts/test_claude_account_worker.py +47 -0
- package/scripts/test_claude_account_worker_process.py +20 -0
- package/scripts/test_claude_account_worker_report.py +15 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/hooks/advisory/conftest.py +0 -10
- package/hooks/advisory/refactor_guard.py +0 -385
- package/hooks/advisory/refactor_guard_test_support.py +0 -46
- package/hooks/advisory/test_refactor_guard.py +0 -148
- package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
- package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
- package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
- package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: correction
|
|
3
|
-
description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake. Use when the user types /correction followed by what the agent got wrong.
|
|
4
|
-
argument-hint: "<what the agent got wrong, in the user's words>"
|
|
3
|
+
description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake, or land the fix in this session with the fix playbook. Use when the user types /correction followed by what the agent got wrong, or /correction fix to land the fix here.
|
|
4
|
+
argument-hint: "[fix] <what the agent got wrong, in the user's words>"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /correction
|
|
8
8
|
|
|
9
|
-
The user corrected an agent and wants the fix to stick. Write one brief
|
|
9
|
+
The user corrected an agent and wants the fix to stick. Write one brief that records the correction. By default the user pastes it into the place that turns corrections into lasting fixes. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself.
|
|
10
10
|
|
|
11
|
-
`$ARGUMENTS` is the user's statement of the correction. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
|
|
11
|
+
`$ARGUMENTS` is the user's statement of the correction, after a leading `fix` when one is present. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
|
|
12
12
|
|
|
13
|
-
Print the brief as one fenced `text` block
|
|
13
|
+
Print the brief as one fenced `text` block:
|
|
14
14
|
|
|
15
15
|
```text
|
|
16
|
-
Correction: <$ARGUMENTS
|
|
16
|
+
Correction: <$ARGUMENTS word for word, minus a leading fix>
|
|
17
17
|
Asked: <what the user asked for, quoted where a message carries it>
|
|
18
18
|
Agent did: <what the agent did, with the reply, file, or command that shows it>
|
|
19
19
|
Corrected to: <what the user wanted instead, or the fix already made>
|
|
@@ -22,4 +22,18 @@ Evidence: <file:line, log line, or quoted message that shows the miss>
|
|
|
22
22
|
Repeat: <yes, with the earlier time it happened | no | unknown>
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Keep each field to one or two lines. Leave out the layer and the fix design; the intake decides those.
|
|
25
|
+
Keep each field to one or two lines. Leave out the layer and the fix design; the intake or the fix playbook decides those.
|
|
26
|
+
|
|
27
|
+
## Playbooks
|
|
28
|
+
|
|
29
|
+
| Mode | What follows the brief |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Hand off, the default | Nothing. The brief is the whole reply. |
|
|
32
|
+
| `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
|
|
33
|
+
|
|
34
|
+
## Layout
|
|
35
|
+
|
|
36
|
+
| File | What it holds |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `SKILL.md` | The brief and the playbook index |
|
|
39
|
+
| `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Fix
|
|
2
|
+
|
|
3
|
+
Use this playbook when this session lands the fix for the correction itself. One correction gets one control and one pull request.
|
|
4
|
+
|
|
5
|
+
1. Write the brief from [`../SKILL.md`](../SKILL.md) first. It is the record every later step reads.
|
|
6
|
+
2. When pstack is loaded, invoke `/pstack:poteto-mode`, then `/correct`. Point `/correct` at the brief's mistake alone, and skip its sweep of the whole repository.
|
|
7
|
+
3. Read the correction instructions of the repository the mistake happened in. Open each `AGENTS.md` or `CLAUDE.md` index line that names corrections, and any intake note it points to. Where they name the target repository, the proof, or the run route, they win over this playbook.
|
|
8
|
+
4. Search for an existing control: a rule, hook, lint, test, or skill that already names this mistake. A control that exists and still let the mistake through makes this a repeat.
|
|
9
|
+
5. Choose the layer with [`correction-lens.md`](../../../../docs/rule-guides/correction-lens.md). When `Repeat` is `yes`, or step 4 found a control, go one layer above the one that failed.
|
|
10
|
+
6. Land the control in the repository whose code, CI, or agents it guards, per the guide's "Where the control lands" section. When that repository is not in this session, add it. When it cannot be added, print the brief, name the missing repository, and stop.
|
|
11
|
+
7. Prove the control. Show it fail on the mistake from the brief's `Evidence` line, then pass with the change, per [`falsify-before-green.md`](../../../../rules/falsify-before-green.md) and [`proof-before-pull-request.md`](../../../../rules/proof-before-pull-request.md). Without that proof, open no pull request.
|
|
12
|
+
8. Open the pull request through `pr-lifecycle`. Put the brief, the layer, and both proof results in the body.
|
|
13
|
+
|
|
14
|
+
**Reply:** the layer chosen, why each higher layer cannot hold the lesson, and the pull request link.
|
|
@@ -25,7 +25,9 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
|
|
|
25
25
|
**Prove the change where it runs before you open the pull request.** The body carries a
|
|
26
26
|
"Proof in practice" section with each command you ran and its quoted output. A hook denies
|
|
27
27
|
a new pull request without one. Read `~/.claude/docs/rule-guides/proof-before-pull-request.md`
|
|
28
|
-
before you plan the proof run.
|
|
28
|
+
before you plan the proof run. A `feat` pull request also needs an "Eval" section and a
|
|
29
|
+
session that invoked `/claude-api build-eval`; read
|
|
30
|
+
`~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature.
|
|
29
31
|
|
|
30
32
|
**Open every pull request ready for review.** Pass `--draft` only when the owner asks
|
|
31
33
|
for a draft.
|
|
@@ -3,7 +3,8 @@ name: second-account-workers
|
|
|
3
3
|
description: >-
|
|
4
4
|
Run local headless Claude workers through the account picker.
|
|
5
5
|
Triggers: spawn a worker on the second account or another extra account, offload a worker,
|
|
6
|
-
use a named Claude profile, save main account usage
|
|
6
|
+
use a named Claude profile, save main account usage, run a local orchestrator that spawns
|
|
7
|
+
and watches Claude workers.
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# Extra account workers
|
|
@@ -22,8 +23,9 @@ JSON list of profile names, with the first choice first. Names use letters,
|
|
|
22
23
|
digits, hyphens, and underscores. The worker uses the existing second profile
|
|
23
24
|
when the file is absent. When the file exists, its list sets the full order.
|
|
24
25
|
Keep `main` and `wait` out of the list because they name picker decisions.
|
|
25
|
-
The picker
|
|
26
|
-
|
|
26
|
+
The picker keeps each account that has room and gives the job to the first one
|
|
27
|
+
listed in `claude-account-order.json` in the main Claude home. Unlisted accounts
|
|
28
|
+
follow, by most room left.
|
|
27
29
|
For direct picker calls, `--second-config-dir` sets the first extra profile and
|
|
28
30
|
each `--extra-config-dir` adds another in the order given.
|
|
29
31
|
Picker JSON prints `config_dir` for the choice and meters under `main`, `second`,
|
|
@@ -47,6 +49,13 @@ worker_exit=$?
|
|
|
47
49
|
cat "<report>"
|
|
48
50
|
```
|
|
49
51
|
|
|
52
|
+
Add `--live-log "<events.jsonl>"` to watch the worker while it runs. The worker
|
|
53
|
+
then writes one JSON event per line into that file as it happens. Every event
|
|
54
|
+
carries the `session_id`, from the first startup hook line on. Each `assistant` line names the tool
|
|
55
|
+
calls and text, and the last line is the `result` event. Read new lines with
|
|
56
|
+
`tail -n 20 "<events.jsonl>"` and skip screenshots unless a check needs to see
|
|
57
|
+
the screen.
|
|
58
|
+
|
|
50
59
|
Read the JSON report and worker log after the process ends. The JSON fields are
|
|
51
60
|
`account`, `reason`, `exit_code`, `duration_seconds`, `result`, and `is_error`.
|
|
52
61
|
The runner writes one summary line with the selected account, exit code, and
|
|
@@ -83,3 +92,12 @@ Blocked: <open issue, or none>
|
|
|
83
92
|
|
|
84
93
|
Keep the owned file list closed. Name the worktree path with `--cwd` and use a
|
|
85
94
|
separate brief and report path for each worker.
|
|
95
|
+
|
|
96
|
+
## Run a local orchestrator
|
|
97
|
+
|
|
98
|
+
To save usage in a long session, start one local orchestrator worker and let it
|
|
99
|
+
spawn the other workers. Give it its own brief that names each worker brief,
|
|
100
|
+
worktree, report path and live log. It launches each worker with the command
|
|
101
|
+
above in the background, reads the live logs to follow progress, and reads each
|
|
102
|
+
report at the end. Each spawn goes through the picker, so every worker lands on
|
|
103
|
+
the account with the most weekly room at its start.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Back to the [rule entry](../../rules/features-start-with-an-eval.md).
|
|
2
|
+
|
|
3
|
+
# Features start with an eval
|
|
4
|
+
|
|
5
|
+
**When this applies:** A user asks for a new feature or a change in behavior, in any project.
|
|
6
|
+
|
|
7
|
+
## What counts
|
|
8
|
+
|
|
9
|
+
A feature is behavior a user or a caller can see that did not exist before, or that now works differently: a command, a flag, a page, an endpoint, a hook, a rule, a skill, a report, or an integration. A bug fix that restores intended behavior, a rename, a refactor, a docs edit, a question, a test run, and speed work that leaves the output the same are not features.
|
|
10
|
+
|
|
11
|
+
An eval is three things for one feature: a set of input cases with their expected results, a runner that drives the feature through the entry point its users call, and a grader that scores each output. A test suite counts when it does all three for that feature.
|
|
12
|
+
|
|
13
|
+
## Rule
|
|
14
|
+
|
|
15
|
+
1. Make the Skill tool call with skill `claude-api` and args `build-eval` your first action on the ask. A user who types `/claude-api build-eval` has made the same call.
|
|
16
|
+
2. Run the existing-work search from [`explore-thoroughly.md`](../../rules/explore-thoroughly.md) next. The guide's own first step also looks for cases, graders, and runners that exist.
|
|
17
|
+
3. Follow the guide the skill loads. It asks the user to approve the inputs and the grading method, and to approve the cost before the first paid run. Ask through the session's question tool. Where the host says to keep working while a question waits, continue on the option you recommended and change course if the answer differs.
|
|
18
|
+
4. Run the eval before the change to get a baseline, build the feature, and run it again.
|
|
19
|
+
5. Give the pull request an "Eval" section. Name the cases and the grader, give the command that runs the eval in backticks, and quote the baseline and the result.
|
|
20
|
+
|
|
21
|
+
## An existing feature with no eval
|
|
22
|
+
|
|
23
|
+
When your work touches an existing feature, look for its eval. When it has none, start a separate session that builds it, and keep doing your own work while that session runs. Your own feature still starts with its own eval.
|
|
24
|
+
|
|
25
|
+
- In a Claude project thread, call `send_message` with the coordinator's session id from `get_channel_session_id`, and ask it to start a thread for the eval. A thread session has no `start_thread_session`, so the coordinator starts that thread.
|
|
26
|
+
- In a project's coordinator session, call `start_thread_session` with the brief.
|
|
27
|
+
- In a command-line session, start a headless session through the account broker: `python ~/.claude/scripts/account_broker.py run --product claude --report <report.json> -- claude -p "<brief>" --model <model> --effort <level>`.
|
|
28
|
+
|
|
29
|
+
The brief names the feature, its entry point, and the first step, the `claude-api` skill with args `build-eval`. An Agent-tool subagent is not a separate session. It shares your context window and ends with your turn.
|
|
30
|
+
|
|
31
|
+
You own that session's result. Check its pull request, and link it from your own pull request's "Eval" section.
|
|
32
|
+
|
|
33
|
+
## Enforcement
|
|
34
|
+
|
|
35
|
+
`hooks/blocking/pull_request_proof.py` reads each new pull request before it opens, through `pr_lifecycle_skill_gate.py`. A new pull request is a `gh pr create` command, a GitHub MCP create call, or a GitHub MCP `run_workflow` dispatch whose inputs name a `head` branch and a `title`, because that workflow opens the pull request. A pull request whose title starts with `feat` (`feat:`, `feat(scope):`, `feat!:`) is denied when:
|
|
36
|
+
|
|
37
|
+
- its body has no "Eval" heading, or the text under that heading names no command in backticks;
|
|
38
|
+
- the session's transcript is readable and shows no `claude-api` Skill call with args `build-eval`, and no `/claude-api build-eval` command.
|
|
39
|
+
|
|
40
|
+
The gate checks that the section and the call are there. Whether the eval measures the feature stays a judgment for the author and the reviewer. The order of the first action and the separate session for an existing feature are not checked by a hook, so the agent applies them.
|
|
@@ -19,16 +19,20 @@ from hooks_constants.followup_pr_dedupe_constants import (
|
|
|
19
19
|
ALL_BODY_FILE_OPTIONS,
|
|
20
20
|
ALL_BODY_OPTIONS,
|
|
21
21
|
ALL_REPOSITORY_OPTIONS,
|
|
22
|
+
ALL_TITLE_OPTIONS,
|
|
22
23
|
CREATE_PULL_REQUEST_TOOL_SUFFIX,
|
|
23
24
|
DUPLICATE_FOLLOWUP_REASON_TEMPLATE,
|
|
24
25
|
FOLLOWUP_PARENT_PATTERN,
|
|
25
26
|
ALL_GH_CREATE_WORDS,
|
|
26
27
|
ALL_GIT_REMOTE_COMMAND_WORDS,
|
|
28
|
+
ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS,
|
|
27
29
|
GITHUB_API_ROOT,
|
|
28
30
|
GITHUB_REMOTE_PATTERN,
|
|
29
31
|
OPEN_PULL_REQUESTS_PATH_TEMPLATE,
|
|
30
32
|
READ_TIMEOUT_SECONDS,
|
|
33
|
+
RUN_WORKFLOW_METHOD,
|
|
31
34
|
STANDARD_INPUT_PATH,
|
|
35
|
+
WORKFLOW_DISPATCH_TOOL_SUFFIX,
|
|
32
36
|
)
|
|
33
37
|
from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
34
38
|
from hooks_constants.pytest_invocation import unquoted_token
|
|
@@ -99,6 +103,18 @@ def body_from_arguments(all_arguments: list[str], working_directory: str) -> str
|
|
|
99
103
|
return None
|
|
100
104
|
|
|
101
105
|
|
|
106
|
+
def title_from_arguments(all_arguments: list[str]) -> str | None:
|
|
107
|
+
"""Return the title a gh-style argument list passes, or None when it passes none.
|
|
108
|
+
|
|
109
|
+
::
|
|
110
|
+
|
|
111
|
+
["--title", "feat: x"] -> "feat: x"
|
|
112
|
+
["-t=fix: y"] -> "fix: y"
|
|
113
|
+
["--fill"] -> None
|
|
114
|
+
"""
|
|
115
|
+
return _option_value(all_arguments, ALL_TITLE_OPTIONS)
|
|
116
|
+
|
|
117
|
+
|
|
102
118
|
def _origin_repository(working_directory: str) -> tuple[str, str] | None:
|
|
103
119
|
try:
|
|
104
120
|
completed = subprocess.run(
|
|
@@ -146,19 +162,53 @@ def _shell_draft(command: str, working_directory: str) -> PullRequestDraft | Non
|
|
|
146
162
|
return next((each_draft for each_draft in all_drafts if each_draft is not None), None)
|
|
147
163
|
|
|
148
164
|
|
|
149
|
-
def
|
|
150
|
-
|
|
165
|
+
def _dispatch_fields(all_tool_input_fields: Mapping[str, object]) -> dict[str, object] | None:
|
|
166
|
+
all_inputs = all_tool_input_fields.get("inputs")
|
|
167
|
+
if all_tool_input_fields.get("method") != RUN_WORKFLOW_METHOD or not isinstance(all_inputs, dict):
|
|
168
|
+
return None
|
|
169
|
+
if not all(isinstance(all_inputs.get(each_key), str) for each_key in ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS):
|
|
170
|
+
return None
|
|
171
|
+
return {**all_inputs, "owner": all_tool_input_fields.get("owner"), "repo": all_tool_input_fields.get("repo")}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_tool_fields(all_payload_fields: Mapping[str, object]) -> Mapping[str, object] | None:
|
|
175
|
+
"""Return the fields a GitHub MCP call that opens a pull request carries, or None.
|
|
176
|
+
|
|
177
|
+
::
|
|
178
|
+
|
|
179
|
+
create_pull_request {owner, repo, title, body} -> those fields
|
|
180
|
+
actions_run_trigger run_workflow, head and title in -> the inputs, owner and repo
|
|
181
|
+
actions_run_trigger whose inputs name no head -> None
|
|
182
|
+
|
|
183
|
+
A dispatch whose inputs name a head branch and a title runs a workflow
|
|
184
|
+
that opens the pull request, so it counts as a create call.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The owner, repo, title, body and head keys the call names, or None.
|
|
191
|
+
"""
|
|
151
192
|
tool_name = all_payload_fields.get("tool_name")
|
|
152
193
|
tool_input = all_payload_fields.get("tool_input")
|
|
153
194
|
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
154
195
|
return None
|
|
155
196
|
if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
|
|
156
|
-
|
|
197
|
+
return tool_input
|
|
198
|
+
return _dispatch_fields(tool_input) if tool_name.endswith(WORKFLOW_DISPATCH_TOOL_SUFFIX) else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def pull_request_draft(all_payload_fields: Mapping[str, object]) -> PullRequestDraft | None:
|
|
202
|
+
"""Return the pull request a PreToolUse payload is about to open, or None."""
|
|
203
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
204
|
+
if create_fields is not None:
|
|
205
|
+
owner, repo, body = (create_fields.get(each_key) for each_key in ("owner", "repo", "body"))
|
|
157
206
|
if isinstance(owner, str) and isinstance(repo, str) and isinstance(body, str):
|
|
158
207
|
return PullRequestDraft(owner, repo, body)
|
|
159
208
|
return None
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
210
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
211
|
+
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
162
212
|
return None
|
|
163
213
|
working_directory = all_payload_fields.get("cwd")
|
|
164
214
|
return _shell_draft(command, working_directory if isinstance(working_directory, str) else "")
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import re
|
|
7
7
|
import shlex
|
|
8
8
|
import sys
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -15,6 +16,7 @@ if hooks_directory not in sys.path:
|
|
|
15
16
|
from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
16
17
|
AGENT_ID_FIELD,
|
|
17
18
|
AGENT_ID_PATTERN,
|
|
19
|
+
ALL_BUILD_EVAL_SKILL_NAMES,
|
|
18
20
|
ALL_API_ACTION_NAMES,
|
|
19
21
|
ALL_COMMAND_PREFIX_WORDS,
|
|
20
22
|
ALL_GITHUB_MCP_TOOL_SUFFIXES,
|
|
@@ -23,6 +25,8 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
23
25
|
ALL_SHELL_WRAPPER_EXECUTABLES,
|
|
24
26
|
ALL_SLASH_COMMAND_MARKERS,
|
|
25
27
|
ALL_TRANSCRIPT_PATH_FIELDS,
|
|
28
|
+
BUILD_EVAL_ARGUMENT_WORD,
|
|
29
|
+
BUILD_EVAL_COMMAND_MARKER,
|
|
26
30
|
COMMAND_SEPARATORS,
|
|
27
31
|
COMMAND_WHITESPACE,
|
|
28
32
|
DENY_DECISION,
|
|
@@ -52,13 +56,19 @@ from hooks_constants.hook_specific_output_keys import (
|
|
|
52
56
|
)
|
|
53
57
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
54
58
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
55
|
-
from blocking.followup_pr_dedupe import duplicate_followup_reason
|
|
59
|
+
from blocking.followup_pr_dedupe import create_tool_fields, duplicate_followup_reason
|
|
56
60
|
from blocking.pull_request_proof import (
|
|
61
|
+
is_feature_pull_request,
|
|
62
|
+
missing_eval_section_reason,
|
|
57
63
|
missing_existing_work_reason,
|
|
58
64
|
missing_look_reason,
|
|
59
65
|
missing_proof_reason,
|
|
60
66
|
)
|
|
61
|
-
from
|
|
67
|
+
from hooks_constants.pull_request_proof_constants import MISSING_BUILD_EVAL_REASON
|
|
68
|
+
from transcript_skill_scan import (
|
|
69
|
+
invokes_skill_with_argument,
|
|
70
|
+
is_skill_loaded_after_last_compaction,
|
|
71
|
+
)
|
|
62
72
|
|
|
63
73
|
|
|
64
74
|
def _command_segments(command: str) -> list[list[str]]:
|
|
@@ -169,7 +179,7 @@ def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
|
|
|
169
179
|
tool_name = all_payload_fields.get("tool_name")
|
|
170
180
|
if not isinstance(tool_name, str):
|
|
171
181
|
return False
|
|
172
|
-
if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES):
|
|
182
|
+
if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES) or create_tool_fields(all_payload_fields) is not None:
|
|
173
183
|
return True
|
|
174
184
|
if tool_name not in SHELL_TOOL_NAMES:
|
|
175
185
|
return False
|
|
@@ -177,16 +187,29 @@ def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
|
|
|
177
187
|
return isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str) and _matches_shell_command(tool_input["command"])
|
|
178
188
|
|
|
179
189
|
|
|
180
|
-
def
|
|
190
|
+
def _scanned_transcript(path: str, scan: Callable[[Iterable[str]], bool]) -> bool | None:
|
|
181
191
|
try:
|
|
182
192
|
with open(path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
183
|
-
return
|
|
184
|
-
transcript, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
|
|
185
|
-
)
|
|
193
|
+
return scan(transcript)
|
|
186
194
|
except OSError:
|
|
187
195
|
return None
|
|
188
196
|
|
|
189
197
|
|
|
198
|
+
def _shows_skill_load(all_transcript_lines: Iterable[str]) -> bool:
|
|
199
|
+
return is_skill_loaded_after_last_compaction(
|
|
200
|
+
all_transcript_lines, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _shows_build_eval(all_transcript_lines: Iterable[str]) -> bool:
|
|
205
|
+
return invokes_skill_with_argument(
|
|
206
|
+
all_transcript_lines,
|
|
207
|
+
ALL_BUILD_EVAL_SKILL_NAMES,
|
|
208
|
+
BUILD_EVAL_ARGUMENT_WORD,
|
|
209
|
+
BUILD_EVAL_COMMAND_MARKER,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
190
213
|
def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | None:
|
|
191
214
|
session_path = all_payload_fields.get(SESSION_TRANSCRIPT_PATH_FIELD)
|
|
192
215
|
agent_id = all_payload_fields.get(AGENT_ID_FIELD)
|
|
@@ -203,7 +226,17 @@ def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | No
|
|
|
203
226
|
|
|
204
227
|
|
|
205
228
|
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
206
|
-
"""Return a deny for
|
|
229
|
+
"""Return a deny for the first rule a pull request action breaks, else None.
|
|
230
|
+
|
|
231
|
+
::
|
|
232
|
+
|
|
233
|
+
pr-lifecycle skill not loaded -> DENY_REASON
|
|
234
|
+
new pull request with no proof section -> MISSING_PROOF_REASON
|
|
235
|
+
new pull request with no existing-work section -> MISSING_EXISTING_WORK_REASON
|
|
236
|
+
feature pull request with no eval section -> MISSING_EVAL_SECTION_REASON
|
|
237
|
+
changed page whose proof shows no picture -> MISSING_LOOK_REASON
|
|
238
|
+
feature pull request, no /claude-api build-eval -> MISSING_BUILD_EVAL_REASON
|
|
239
|
+
second follow-up pull request for one parent -> DUPLICATE_FOLLOWUP_REASON_TEMPLATE
|
|
207
240
|
|
|
208
241
|
Args:
|
|
209
242
|
all_payload_fields: The parsed PreToolUse input.
|
|
@@ -218,23 +251,41 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
218
251
|
search_reason = missing_existing_work_reason(all_payload_fields)
|
|
219
252
|
if search_reason is not None:
|
|
220
253
|
return _deny(search_reason)
|
|
254
|
+
eval_reason = missing_eval_section_reason(all_payload_fields)
|
|
255
|
+
if eval_reason is not None:
|
|
256
|
+
return _deny(eval_reason)
|
|
221
257
|
look_reason = missing_look_reason(all_payload_fields)
|
|
222
258
|
if look_reason is not None:
|
|
223
259
|
return _deny(look_reason)
|
|
260
|
+
if is_feature_pull_request(all_payload_fields) and _is_build_eval_missing(all_payload_fields):
|
|
261
|
+
return _deny(MISSING_BUILD_EVAL_REASON)
|
|
224
262
|
duplicate_reason = duplicate_followup_reason(all_payload_fields)
|
|
225
263
|
return None if duplicate_reason is None else _deny(duplicate_reason)
|
|
226
264
|
|
|
227
265
|
|
|
228
|
-
def
|
|
266
|
+
def _transcript_paths(all_payload_fields: dict[str, object]) -> list[str]:
|
|
229
267
|
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
230
268
|
paths.append(_subagent_transcript_path(all_payload_fields))
|
|
231
|
-
|
|
269
|
+
return list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
273
|
+
readable_paths = _transcript_paths(all_payload_fields)
|
|
232
274
|
if not readable_paths:
|
|
233
275
|
return False
|
|
234
|
-
statuses = [
|
|
276
|
+
statuses = [_scanned_transcript(path, _shows_skill_load) for path in readable_paths]
|
|
235
277
|
return not any(status is None or status for status in statuses)
|
|
236
278
|
|
|
237
279
|
|
|
280
|
+
def _is_build_eval_missing(all_payload_fields: dict[str, object]) -> bool:
|
|
281
|
+
all_statuses = [
|
|
282
|
+
_scanned_transcript(each_path, _shows_build_eval)
|
|
283
|
+
for each_path in _transcript_paths(all_payload_fields)
|
|
284
|
+
]
|
|
285
|
+
all_read_statuses = [each_status for each_status in all_statuses if each_status is not None]
|
|
286
|
+
return bool(all_read_statuses) and not any(all_read_statuses)
|
|
287
|
+
|
|
288
|
+
|
|
238
289
|
def _deny(reason: str) -> dict[str, object]:
|
|
239
290
|
return {
|
|
240
291
|
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section."""
|
|
1
|
+
"""Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section, or a feature pull request whose body lacks an 'Eval' section."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -6,13 +6,14 @@ import re
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
8
|
from collections.abc import Mapping
|
|
9
|
+
from typing import NamedTuple
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
12
13
|
if hooks_directory not in sys.path:
|
|
13
14
|
sys.path.insert(0, hooks_directory)
|
|
14
15
|
|
|
15
|
-
from blocking.followup_pr_dedupe import body_from_arguments
|
|
16
|
+
from blocking.followup_pr_dedupe import body_from_arguments, create_tool_fields, title_from_arguments
|
|
16
17
|
from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
17
18
|
from hooks_constants.pull_request_proof_constants import (
|
|
18
19
|
ALL_GH_CREATE_WORDS,
|
|
@@ -20,16 +21,18 @@ from hooks_constants.pull_request_proof_constants import (
|
|
|
20
21
|
ALL_VISIBLE_FILE_SUFFIXES,
|
|
21
22
|
CHANGED_FILES_SEPARATOR,
|
|
22
23
|
COMMAND_MARKER,
|
|
23
|
-
CREATE_PULL_REQUEST_TOOL_SUFFIX,
|
|
24
24
|
ALL_DEFAULT_BRANCH_REFERENCES,
|
|
25
25
|
ALL_HEAD_FLAGS,
|
|
26
26
|
HEAD_FLAG_ASSIGNMENT_PREFIX,
|
|
27
27
|
REMOTE_BRANCH_PREFIX,
|
|
28
28
|
UNREADABLE_CHANGES_REASON,
|
|
29
|
+
EVAL_HEADING_PATTERN,
|
|
29
30
|
EXISTING_WORK_HEADING_PATTERN,
|
|
31
|
+
FEATURE_TITLE_PATTERN,
|
|
30
32
|
GIT_TIMEOUT_SECONDS,
|
|
31
33
|
LINE_SEPARATOR,
|
|
32
34
|
MISSING_BODY_REASON,
|
|
35
|
+
MISSING_EVAL_SECTION_REASON,
|
|
33
36
|
MISSING_EXISTING_WORK_REASON,
|
|
34
37
|
MISSING_LOOK_REASON,
|
|
35
38
|
MISSING_PROOF_REASON,
|
|
@@ -48,6 +51,13 @@ from hooks_constants.shell_command_wrappers import (
|
|
|
48
51
|
)
|
|
49
52
|
|
|
50
53
|
|
|
54
|
+
class CreateCall(NamedTuple):
|
|
55
|
+
"""The title and body one pull request create call passes; None marks a value this gate cannot read."""
|
|
56
|
+
|
|
57
|
+
title: str | None
|
|
58
|
+
body: str | None
|
|
59
|
+
|
|
60
|
+
|
|
51
61
|
def _heading_level(line: str, heading_pattern: str) -> int | None:
|
|
52
62
|
heading = re.match(heading_pattern, line.strip(), re.IGNORECASE)
|
|
53
63
|
return None if heading is None else len(heading.group(1))
|
|
@@ -120,9 +130,12 @@ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
|
|
|
120
130
|
return _script_create_arguments(program, all_arguments)
|
|
121
131
|
|
|
122
132
|
|
|
123
|
-
def
|
|
133
|
+
def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall]:
|
|
124
134
|
return [
|
|
125
|
-
|
|
135
|
+
CreateCall(
|
|
136
|
+
title_from_arguments(all_create_arguments),
|
|
137
|
+
body_from_arguments(all_create_arguments, working_directory),
|
|
138
|
+
)
|
|
126
139
|
for each_text in all_wrapped_command_texts(command)
|
|
127
140
|
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
128
141
|
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
@@ -139,13 +152,12 @@ def _head_flag_value(all_create_arguments: list[str]) -> str | None:
|
|
|
139
152
|
|
|
140
153
|
|
|
141
154
|
def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
return None
|
|
145
|
-
head_branch = tool_input.get("head")
|
|
155
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
156
|
+
head_branch = None if create_fields is None else create_fields.get("head")
|
|
146
157
|
if isinstance(head_branch, str):
|
|
147
158
|
return head_branch
|
|
148
|
-
|
|
159
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
160
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
149
161
|
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
150
162
|
return None
|
|
151
163
|
all_head_flag_values = [
|
|
@@ -157,23 +169,77 @@ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
157
169
|
return next((each_value for each_value in all_head_flag_values if each_value), None)
|
|
158
170
|
|
|
159
171
|
|
|
160
|
-
def
|
|
161
|
-
|
|
172
|
+
def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
173
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
174
|
+
if create_fields is not None:
|
|
175
|
+
title = create_fields.get("title")
|
|
176
|
+
body = create_fields.get("body")
|
|
177
|
+
return [
|
|
178
|
+
CreateCall(
|
|
179
|
+
title if isinstance(title, str) else None,
|
|
180
|
+
body if isinstance(body, str) else "",
|
|
181
|
+
)
|
|
182
|
+
]
|
|
162
183
|
tool_input = all_payload_fields.get("tool_input")
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
|
|
166
|
-
body = tool_input.get("body")
|
|
167
|
-
return [body if isinstance(body, str) else ""]
|
|
168
|
-
command = tool_input.get("command")
|
|
169
|
-
if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
184
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
185
|
+
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
170
186
|
return []
|
|
171
187
|
working_directory = all_payload_fields.get("cwd")
|
|
172
|
-
return
|
|
188
|
+
return _shell_create_calls(
|
|
173
189
|
command, working_directory if isinstance(working_directory, str) else ""
|
|
174
190
|
)
|
|
175
191
|
|
|
176
192
|
|
|
193
|
+
def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
|
|
194
|
+
return [each_call.body for each_call in _create_calls(all_payload_fields)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_feature_title(title: str | None) -> bool:
|
|
198
|
+
return title is not None and re.match(FEATURE_TITLE_PATTERN, title, re.IGNORECASE) is not None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_feature_pull_request(all_payload_fields: Mapping[str, object]) -> bool:
|
|
202
|
+
"""Return whether any pull request create call in the payload carries a feature title.
|
|
203
|
+
|
|
204
|
+
::
|
|
205
|
+
|
|
206
|
+
mcp__github__create_pull_request, title "feat(hooks)!: x" -> True
|
|
207
|
+
gh pr create --title "fix: y" --body-file b.md -> False
|
|
208
|
+
gh pr create --fill -> False
|
|
209
|
+
gh pr edit, git push, any other tool -> False
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
213
|
+
"""
|
|
214
|
+
return any(_is_feature_title(each_call.title) for each_call in _create_calls(all_payload_fields))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def missing_eval_section_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
218
|
+
"""Return a deny reason when a feature pull request's body has no eval section, else None.
|
|
219
|
+
|
|
220
|
+
::
|
|
221
|
+
|
|
222
|
+
title "feat: x", body has the section -> None
|
|
223
|
+
title "feat: x", body without it -> MISSING_EVAL_SECTION_REASON
|
|
224
|
+
title "fix: y", body without it -> None
|
|
225
|
+
title "feat: x", gh pr create --fill -> None
|
|
226
|
+
|
|
227
|
+
The section is an 'Eval' heading whose text names at least one command in
|
|
228
|
+
backticks. A create command whose body this gate cannot read passes here,
|
|
229
|
+
because missing_proof_reason denies it first.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
233
|
+
"""
|
|
234
|
+
for each_call in _create_calls(all_payload_fields):
|
|
235
|
+
if each_call.body is None or not _is_feature_title(each_call.title):
|
|
236
|
+
continue
|
|
237
|
+
section = _section_text(each_call.body, EVAL_HEADING_PATTERN)
|
|
238
|
+
if not section or COMMAND_MARKER not in section:
|
|
239
|
+
return MISSING_EVAL_SECTION_REASON
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
177
243
|
def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
178
244
|
"""Return a deny reason when a new pull request's body has no proof, else None.
|
|
179
245
|
|
|
@@ -101,6 +101,7 @@ def test_should_refuse_a_page_published_without_a_look(tmp_path: Path) -> None:
|
|
|
101
101
|
specific_output = hook_output["hookSpecificOutput"]
|
|
102
102
|
assert specific_output["permissionDecision"] == "deny"
|
|
103
103
|
assert "--screenshot" in specific_output["permissionDecisionReason"]
|
|
104
|
+
assert "Attach" not in specific_output["permissionDecisionReason"]
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
def test_should_allow_a_page_whose_newer_screenshot_was_read(tmp_path: Path) -> None:
|
|
@@ -92,3 +92,36 @@ def test_body_from_arguments_reads_inline_text_and_body_files(tmp_path: Path) ->
|
|
|
92
92
|
assert dedupe.body_from_arguments(["--body-file", "body.md"], str(tmp_path)) == "From the file."
|
|
93
93
|
assert dedupe.body_from_arguments(["--body-file", "-"], str(tmp_path)) is None
|
|
94
94
|
assert dedupe.body_from_arguments(["--fill"], str(tmp_path)) is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_title_from_arguments_should_read_each_title_flag_shape() -> None:
|
|
98
|
+
assert dedupe.title_from_arguments(["--title", "feat: x", "--fill"]) == "feat: x"
|
|
99
|
+
assert dedupe.title_from_arguments(["-t", "fix: y"]) == "fix: y"
|
|
100
|
+
assert dedupe.title_from_arguments(["--title=feat(x): z"]) == "feat(x): z"
|
|
101
|
+
assert dedupe.title_from_arguments(["--title-file", "t.txt"]) is None
|
|
102
|
+
assert dedupe.title_from_arguments(["--fill"]) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dispatch_payload(all_inputs: dict[str, object]) -> dict[str, object]:
|
|
106
|
+
return {
|
|
107
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
108
|
+
"tool_input": {"method": "run_workflow", "owner": "jl-cmd", "repo": "claude-dev-env", "inputs": all_inputs},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_create_tool_fields_should_read_a_pull_request_dispatch_as_a_create() -> None:
|
|
113
|
+
payload = _dispatch_payload({"head": "b", "title": "fix: y", "body": "Follow-up to #1731"})
|
|
114
|
+
assert dedupe.create_tool_fields(payload) == {
|
|
115
|
+
"head": "b",
|
|
116
|
+
"title": "fix: y",
|
|
117
|
+
"body": "Follow-up to #1731",
|
|
118
|
+
"owner": "jl-cmd",
|
|
119
|
+
"repo": "claude-dev-env",
|
|
120
|
+
}
|
|
121
|
+
reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
|
|
122
|
+
assert reason is not None and "#1769" in reason
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_create_tool_fields_should_skip_a_call_that_opens_no_pull_request() -> None:
|
|
126
|
+
assert dedupe.create_tool_fields(_dispatch_payload({"work_order": "wo-1"})) is None
|
|
127
|
+
assert dedupe.create_tool_fields({"tool_name": "Bash", "tool_input": {"command": "gh pr list"}}) is None
|