claude-dev-env 8.85.3 → 8.87.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/deepen/SKILL.md +5 -21
- package/.agents/skills/efficient-review/README.md +44 -0
- package/.agents/skills/efficient-review/SKILL.md +14 -61
- package/.agents/skills/efficient-review/reference/criteria.md +21 -0
- package/.agents/skills/efficient-review/tools/__init__.py +1 -0
- package/.agents/skills/efficient-review/tools/config/__init__.py +1 -0
- package/.agents/skills/efficient-review/tools/config/efficient_review_constants.py +28 -0
- package/.agents/skills/efficient-review/tools/efficient_review.py +34 -0
- package/.agents/skills/efficient-review/tools/efficient_review_plan.py +95 -0
- package/.agents/skills/efficient-review/tools/tests/test_review_plan.py +94 -0
- package/.agents/skills/search-before-acting/SKILL.md +4 -3
- package/.agents/skills/search-before-acting/playbooks/build.md +1 -1
- package/.agents/skills/search-before-acting/playbooks/plan-or-log.md +1 -1
- package/.agents/skills/search-before-acting/references/report-shape.md +1 -1
- package/bin/codex-skill-load-block.mjs +4 -4
- package/bin/install.prune.test.mjs +10 -4
- package/commands/deepen.md +3 -8
- package/docs/account-broker.md +2 -2
- package/docs/codex-accounts.md +1 -1
- package/docs/decisions/deepstack-gate.md +28 -0
- package/docs/rule-guides/agent-board.md +2 -2
- package/docs/rule-guides/explore-thoroughly.md +2 -2
- package/docs/rule-guides/features-start-with-an-eval.md +2 -2
- package/docs/second-claude-account.md +1 -1
- package/hooks/features/session-context.md +1 -1
- package/hooks/hooks_constants/deepen_first_prompt_constants.py +60 -0
- package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +47 -7
- package/hooks/session/deepen_first_prompt.py +31 -7
- package/hooks/session/deepen_role_gate.py +171 -0
- package/hooks/session/fixtures/deepstack_replay.json +15 -0
- package/hooks/session/grade_deepstack_replay.py +78 -0
- package/hooks/session/test_deepen_first_prompt.py +127 -23
- package/hooks/session/test_grade_deepstack_replay.py +47 -0
- package/package.json +1 -1
- package/rules/agent-board.md +1 -1
- package/rules/index.md +2 -2
- package/scripts/account_broker.py +1 -1
- package/scripts/account_broker_support.py +3 -3
- package/scripts/ci/install-playtest.mjs +3 -0
- package/scripts/codex_account_choice.py +5 -5
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +3 -3
- package/scripts/test_codex_account_choice.py +1 -1
- package/.agents/skills/efficient-review/defaults.json +0 -12
- package/.agents/skills/efficient-review/reference/cloud-session.md +0 -13
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# DeepStack gate
|
|
2
|
+
|
|
3
|
+
The first-task hook is the control. A rewrite ships only when `refusal_reason` in `hooks/session/deepen_role_gate.py` returns no reason. DeepStack, the wording rule, and the review rule cite this function. They do not restate it.
|
|
4
|
+
|
|
5
|
+
## What the unchanged hook did
|
|
6
|
+
|
|
7
|
+
Recorded before this gate edited `deepen_first_prompt.py`, with `CDE_DEEPEN_OUTPUT=json` on stdin.
|
|
8
|
+
|
|
9
|
+
The paste path, a first task and no rewrite stub, printed `decision` `deepen`. The rewritten prompt restated the four question stems and ended by telling the same agent to do the job.
|
|
10
|
+
|
|
11
|
+
The filled four-answer stub printed `decision` `deepen`. The rewritten prompt carried the four answer lines and no role lines.
|
|
12
|
+
|
|
13
|
+
## What the gate refuses
|
|
14
|
+
|
|
15
|
+
| Reason | When |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `restates-four-questions` | The rewrite contains every canonical question stem. The paste path is this case. |
|
|
18
|
+
| `missing-role-lines` | `SCOPE (role):`, `EXAMPLES (illustration only):`, or `ZOOM-IN BLOCKER:` is missing or blank. |
|
|
19
|
+
| `example-noun-in-scope` | The scope line contains an example noun and `ZOOM-IN BLOCKER:` is `none`. |
|
|
20
|
+
| `dropped-hard-constraint` | A bullet under `HARD CONSTRAINTS` in the task is absent as its own line in the rewrite. |
|
|
21
|
+
|
|
22
|
+
Json mode prints `{"decision": "refuse", "reason": "..."}`. Rewrite mode and the command-hook path print nothing, so the prompt-submit mod leaves the submitted text in place.
|
|
23
|
+
|
|
24
|
+
## Proof shape
|
|
25
|
+
|
|
26
|
+
Run the hook on stdin with `CDE_DEEPEN_OUTPUT=json`. The paste path refuses. A role rewrite that leaves the example noun out deepens. Removing `refusal_reason` from `decide_first_prompt` lets the paste path deepen again, and `test_should_refuse_the_paste_path_as_the_known_bad` fails. The wrong twin puts the example noun in the scope line with `ZOOM-IN BLOCKER: none` and is refused.
|
|
27
|
+
|
|
28
|
+
`hooks/session/grade_deepstack_replay.py` scores a fixture whose task names one failure as an example. It calls `refusal_reason` and does not rewrite.
|
|
@@ -4,7 +4,7 @@ Back to the [rules index](../../rules/index.md).
|
|
|
4
4
|
|
|
5
5
|
## In brief
|
|
6
6
|
|
|
7
|
-
When `NTFY_SERVER` is set, the session can reach the agent board, an ntfy server. Read `board-rules.md` in the
|
|
7
|
+
When `NTFY_SERVER` is set, the session can reach the agent board, an ntfy server. Read `board-rules.md` in the repository your project instructions name and follow it. Name the sender project in every post title. On the first board use in a session, post `joined: <project>` to this project's channel from `projects.md`.
|
|
8
8
|
|
|
9
9
|
**Enforcement:** none.
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ When `NTFY_SERVER` is set, the session can reach the agent board, an ntfy server
|
|
|
12
12
|
|
|
13
13
|
## Rule
|
|
14
14
|
|
|
15
|
-
1. Find the
|
|
15
|
+
1. Find the repository your project instructions name for the board. Attach it with the `mcp__claude-code-remote__add_repo` tool if the session lacks it.
|
|
16
16
|
2. Read `board-rules.md` and the README in the attached checkout. When `board-rules.md` is missing, skip the board and say so in the report. Follow `board-rules.md` for every post and every listen. The README section "How to listen" has the listen commands. These rules rank above `board-rules.md`.
|
|
17
17
|
3. Take the project name from your project instructions. When they name no project, ask your coordinator with `send_message` and wait for the answer before you post. A session with no coordinator skips the board and says so in its report.
|
|
18
18
|
4. Every project shares one board token, so the token does not name the sender. Do not call `/v1/account` to find the project name.
|
|
@@ -6,7 +6,7 @@ Back to the [rules index](../../rules/index.md).
|
|
|
6
6
|
|
|
7
7
|
**When:** Before you log, plan, delegate, or build an ask, or choose an approach.
|
|
8
8
|
|
|
9
|
-
Search for what already does the ask, in full or in part. When something exists,
|
|
9
|
+
Search for what already does the ask, in full or in part. When something exists, report it to the user in four points and run the `search-before-acting` skill. When the role-level job is already authorized, continue that job. Then read files, patterns, and constraints, scaled to risk. Once the files, constraints, and success condition are known, act without repeating settled research.
|
|
10
10
|
|
|
11
11
|
**Enforcement:** the pull request gate denies a body with no "Existing work" section.
|
|
12
12
|
|
|
@@ -18,7 +18,7 @@ Note: This deliberately chooses exploration depth over the "commit and execute q
|
|
|
18
18
|
|
|
19
19
|
Before you log, plan, delegate, or build an ask, search for what already does it in full or in part. An ask is any request for new or changed behavior, a tool, a check, a workflow, a report, a tracker entry, or a plan. The `search-before-acting` skill holds the search places, the four-point report, and a playbook for each next move.
|
|
20
20
|
|
|
21
|
-
When the search finds something,
|
|
21
|
+
When the search finds something, report what exists and where, what it covers of the ask, what is missing, and what the addition would add. When the role-level job is already authorized, continue that job. When it covers the whole ask, tell the user they already have it. When the search finds nothing, say where you looked and continue.
|
|
22
22
|
|
|
23
23
|
[`prefer-existing-tools.md`](../../rules/prefer-existing-tools.md) and [`build-needs-a-user.md`](../../rules/build-needs-a-user.md) load only when a session touches a script, hook, or manifest path. This rule loads in every session, so the search comes before the plan is set.
|
|
24
24
|
|
|
@@ -4,7 +4,7 @@ Back to the [rules index](../../rules/index.md).
|
|
|
4
4
|
|
|
5
5
|
## In brief
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The DeepStack reproducible-check leaf calls this guide when the work is a new feature or a change in behavior. Only the forced position as the very first action is retired. A reproducible, re-runnable check is still required before any done claim. Red before the fix, the wrong twin fails, and undo fails again. Build the feature against that eval. When the work touches an existing feature with no eval, start a separate session to build that eval in parallel. An Agent-tool subagent does not count. You own its result.
|
|
8
8
|
|
|
9
9
|
**Enforcement:** `hooks/blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file before the build-eval call. `hooks/blocking/pr_lifecycle_skill_gate.py` denies a `feat` pull request with no "Eval" section or no build-eval call.
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ An eval is three things for one feature: a set of input cases with their expecte
|
|
|
18
18
|
|
|
19
19
|
## Rule
|
|
20
20
|
|
|
21
|
-
1.
|
|
21
|
+
1. Call the Skill tool with skill `claude-api` and args `build-eval` before the change. A user who types `/claude-api build-eval` has made the same call. A reproducible, re-runnable check is still required before any done claim. Red before the fix, the wrong twin fails, and undo fails again. Only the forced position as the very first action is retired.
|
|
22
22
|
2. Run the existing-work search from [`explore-thoroughly.md`](explore-thoroughly.md) next. The guide's own first step also looks for cases, graders, and runners that exist.
|
|
23
23
|
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.
|
|
24
24
|
4. Run the eval before the change to get a baseline, build the feature, and run it again.
|
|
@@ -9,7 +9,7 @@ keeps its own sign-in and history.
|
|
|
9
9
|
| File | What it does |
|
|
10
10
|
|---|---|
|
|
11
11
|
| `scripts/claude_account_profile.py` | Links each shared entry of `~/.claude` into the profile `~/.claude-profiles/ev`, moves stale copies into `.replaced/<time>/`, and writes the `claude-ev.cmd` launcher into `~/.local/bin` |
|
|
12
|
-
| `scripts/account_broker.py` | Reads the account
|
|
12
|
+
| `scripts/account_broker.py` | Reads the account list and usage meters, chooses an account, and runs Claude with that account's home |
|
|
13
13
|
| `scripts/claude_account_worker.py` | Runs one headless Claude worker through the broker |
|
|
14
14
|
| `scripts/claude_account_worker_process.py` | Sends the brief to the broker and measures the run |
|
|
15
15
|
| `scripts/claude_account_worker_report.py` | Builds and writes the JSON report a worker leaves behind |
|
|
@@ -32,7 +32,7 @@ Preconditions:
|
|
|
32
32
|
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
33
33
|
- **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
|
|
34
34
|
- **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
|
|
35
|
-
- **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table and a rewrite that
|
|
35
|
+
- **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, and a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines.
|
|
36
36
|
- **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
|
|
37
37
|
- **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
|
|
38
38
|
|
|
@@ -26,6 +26,22 @@ __all__ = [
|
|
|
26
26
|
"DEEPEN_OUTPUT_JSON",
|
|
27
27
|
"DEEPEN_OUTPUT_REWRITE",
|
|
28
28
|
"DEEPEN_REWRITE_ENV_VAR",
|
|
29
|
+
"ALL_EXAMPLE_NOUN_STOPWORDS",
|
|
30
|
+
"ALL_FOUR_QUESTION_STEMS",
|
|
31
|
+
"EXAMPLE_NOUN_PATTERN",
|
|
32
|
+
"HARD_CONSTRAINTS_LABEL",
|
|
33
|
+
"REFUSAL_DROPPED_HARD_CONSTRAINT",
|
|
34
|
+
"REFUSAL_EXAMPLE_NOUN_IN_SCOPE",
|
|
35
|
+
"REFUSAL_MISSING_ROLE_LINES",
|
|
36
|
+
"REFUSAL_RESTATES_FOUR_QUESTIONS",
|
|
37
|
+
"ROLE_BLOCKER_LABEL",
|
|
38
|
+
"ROLE_BLOCKER_NONE",
|
|
39
|
+
"ROLE_EXAMPLES_LABEL",
|
|
40
|
+
"BULLET_PREFIX",
|
|
41
|
+
"QUESTION_LINE_SEPARATOR",
|
|
42
|
+
"SCORE_ACCEPT",
|
|
43
|
+
"SCORE_REFUSE",
|
|
44
|
+
"ROLE_SCOPE_LABEL",
|
|
29
45
|
"DEEPEN_SLASH_COMMAND",
|
|
30
46
|
"DEEPEN_TIMEOUT_SECONDS",
|
|
31
47
|
"DEEPEN_TURN_MARKER_SUFFIX",
|
|
@@ -117,3 +133,47 @@ ALL_SPAWN_REWRITE_PREFIXES: tuple[str, ...] = (
|
|
|
117
133
|
CLAUDE_SUBAGENT_PROMPT_PREFIX,
|
|
118
134
|
CODEX_SUBAGENT_PROMPT_PREFIX,
|
|
119
135
|
)
|
|
136
|
+
|
|
137
|
+
ROLE_SCOPE_LABEL: str = "SCOPE (role):"
|
|
138
|
+
ROLE_EXAMPLES_LABEL: str = "EXAMPLES (illustration only):"
|
|
139
|
+
ROLE_BLOCKER_LABEL: str = "ZOOM-IN BLOCKER:"
|
|
140
|
+
ROLE_BLOCKER_NONE: str = "none"
|
|
141
|
+
HARD_CONSTRAINTS_LABEL: str = "HARD CONSTRAINTS"
|
|
142
|
+
REFUSAL_RESTATES_FOUR_QUESTIONS: str = "restates-four-questions"
|
|
143
|
+
REFUSAL_MISSING_ROLE_LINES: str = "missing-role-lines"
|
|
144
|
+
REFUSAL_EXAMPLE_NOUN_IN_SCOPE: str = "example-noun-in-scope"
|
|
145
|
+
REFUSAL_DROPPED_HARD_CONSTRAINT: str = "dropped-hard-constraint"
|
|
146
|
+
BULLET_PREFIX: str = "- "
|
|
147
|
+
QUESTION_LINE_SEPARATOR: str = "\n"
|
|
148
|
+
SCORE_ACCEPT: str = "accept"
|
|
149
|
+
SCORE_REFUSE: str = "refuse"
|
|
150
|
+
EXAMPLE_NOUN_PATTERN: str = r"[A-Za-z][A-Za-z0-9_+-]{3,}"
|
|
151
|
+
ALL_FOUR_QUESTION_STEMS: tuple[str, ...] = (
|
|
152
|
+
"What a top person in that field would do that this prompt omitted.",
|
|
153
|
+
"The raw source to start from.",
|
|
154
|
+
"The methods and tools they would use.",
|
|
155
|
+
"What besides the agent or Jon would show the result is right.",
|
|
156
|
+
)
|
|
157
|
+
ALL_EXAMPLE_NOUN_STOPWORDS: frozenset[str] = frozenset(
|
|
158
|
+
{
|
|
159
|
+
"none",
|
|
160
|
+
"the",
|
|
161
|
+
"and",
|
|
162
|
+
"for",
|
|
163
|
+
"with",
|
|
164
|
+
"from",
|
|
165
|
+
"that",
|
|
166
|
+
"this",
|
|
167
|
+
"only",
|
|
168
|
+
"illustration",
|
|
169
|
+
"example",
|
|
170
|
+
"into",
|
|
171
|
+
"when",
|
|
172
|
+
"your",
|
|
173
|
+
"their",
|
|
174
|
+
"about",
|
|
175
|
+
"such",
|
|
176
|
+
"like",
|
|
177
|
+
"say",
|
|
178
|
+
}
|
|
179
|
+
)
|
|
@@ -7,23 +7,63 @@ the deny reason that gives the headless slim-profile command.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
HAIKU_MODEL_FAMILY = "haiku"
|
|
10
|
+
SLIM_PROFILE_NAME = "slim"
|
|
11
|
+
SLIM_PROFILE_EXECUTABLE = "claude"
|
|
12
|
+
SLIM_PROFILE_SETTINGS_JSON = (
|
|
13
|
+
'{"enabledPlugins": {}, "autoMemoryEnabled": false, "autoCompactWindow": 100000}'
|
|
14
|
+
)
|
|
15
|
+
SLIM_PROFILE_MCP_CONFIG_JSON = '{"mcpServers": {}}'
|
|
16
|
+
SHELL_ARGUMENT_SEPARATOR = " "
|
|
17
|
+
ENVIRONMENT_NAME_SEPARATOR = " and "
|
|
18
|
+
JSON_ARGUMENT_PREFIX = "{"
|
|
19
|
+
ALL_SLIM_PROFILE_ENVIRONMENT = (
|
|
20
|
+
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
|
21
|
+
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
|
22
|
+
)
|
|
23
|
+
ALL_SLIM_PROFILE_ARGUMENTS = (
|
|
24
|
+
"-p",
|
|
25
|
+
"--model",
|
|
26
|
+
HAIKU_MODEL_FAMILY,
|
|
27
|
+
"--setting-sources",
|
|
28
|
+
"local",
|
|
29
|
+
"--settings",
|
|
30
|
+
SLIM_PROFILE_SETTINGS_JSON,
|
|
31
|
+
"--strict-mcp-config",
|
|
32
|
+
"--mcp-config",
|
|
33
|
+
SLIM_PROFILE_MCP_CONFIG_JSON,
|
|
34
|
+
"--autocompact",
|
|
35
|
+
"100k",
|
|
36
|
+
)
|
|
10
37
|
HOOK_EVENT_NAME = "PreToolUse"
|
|
11
38
|
TOOL_INPUT_KEY = "tool_input"
|
|
12
39
|
MODEL_INPUT_KEY = "model"
|
|
13
40
|
DENY_DECISION = "deny"
|
|
14
41
|
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _shell_argument(argument: str) -> str:
|
|
45
|
+
if argument.startswith(JSON_ARGUMENT_PREFIX):
|
|
46
|
+
return f"'{argument}'"
|
|
47
|
+
return argument
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SLIM_PROFILE_SHELL_COMMAND = SHELL_ARGUMENT_SEPARATOR.join(
|
|
51
|
+
(
|
|
52
|
+
SLIM_PROFILE_EXECUTABLE,
|
|
53
|
+
*(_shell_argument(each_argument) for each_argument in ALL_SLIM_PROFILE_ARGUMENTS),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
SLIM_PROFILE_ENVIRONMENT_TEXT = ENVIRONMENT_NAME_SEPARATOR.join(ALL_SLIM_PROFILE_ENVIRONMENT)
|
|
15
57
|
SLIM_PROFILE_DENY_REASON = (
|
|
16
58
|
"BLOCKED: A Haiku agent started from this session runs with this session's settings, plugins, "
|
|
17
59
|
"MCP servers, and CLAUDE.md files. An Agent, Task, or thread spawn has no field for setting sources, "
|
|
18
60
|
"a plugin directory, or an MCP config, so it cannot load the slim profile. "
|
|
19
|
-
"Start Haiku as a headless run with the slim profile. Set
|
|
20
|
-
"
|
|
61
|
+
"Start Haiku as a headless run with the slim profile. Set "
|
|
62
|
+
f"{SLIM_PROFILE_ENVIRONMENT_TEXT} in the environment, then run this from Bash or another POSIX shell, "
|
|
21
63
|
"because PowerShell strips the double quotes inside the single-quoted JSON: "
|
|
22
64
|
'python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <report.json> -- '
|
|
23
|
-
"
|
|
24
|
-
'--settings \'{"enabledPlugins": {}, "autoMemoryEnabled": false, "autoCompactWindow": 100000}\' '
|
|
25
|
-
"--strict-mcp-config --mcp-config '{\"mcpServers\": {}}' --autocompact 100k. "
|
|
65
|
+
f"{SLIM_PROFILE_SHELL_COMMAND}. "
|
|
26
66
|
"Send the task on standard input. In a cloud session, run the same claude -p command without the broker. "
|
|
27
|
-
"Where the efficient-review skill is installed,
|
|
28
|
-
"
|
|
67
|
+
"Where the efficient-review skill is installed, its plan prints that claude -p command without the broker. "
|
|
68
|
+
"To keep the agent in this session, spawn it on opus or sonnet."
|
|
29
69
|
)
|
|
@@ -26,6 +26,8 @@ if _hooks_dir not in sys.path:
|
|
|
26
26
|
from hooks_constants.cloud_session import is_cloud_session
|
|
27
27
|
from hooks_constants.deepen_first_prompt_constants import (
|
|
28
28
|
ADDITIONAL_CONTEXT_KEY,
|
|
29
|
+
ALL_FOUR_QUESTION_STEMS,
|
|
30
|
+
QUESTION_LINE_SEPARATOR,
|
|
29
31
|
ALL_HELPER_REPORT_MARKERS,
|
|
30
32
|
ALL_LOOP_PROMPT_PREFIXES,
|
|
31
33
|
ALL_NONINTERACTIVE_ENV_TRUE_VALUES,
|
|
@@ -63,6 +65,7 @@ from hooks_constants.deepen_first_prompt_constants import (
|
|
|
63
65
|
from hooks_constants.mod_handoff import is_mod_plugin_enabled
|
|
64
66
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
65
67
|
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
68
|
+
from deepen_role_gate import refusal_reason
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
class SkipReason(StrEnum):
|
|
@@ -94,7 +97,14 @@ class Deepen:
|
|
|
94
97
|
additional_context: str
|
|
95
98
|
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Refuse:
|
|
102
|
+
"""Leave the submitted prompt unchanged because the candidate rewrite failed."""
|
|
103
|
+
|
|
104
|
+
reason: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
DeepenDecision = Skip | Deepen | Refuse
|
|
98
108
|
|
|
99
109
|
|
|
100
110
|
def submitted_prompt_text(all_hook_fields: dict[str, object]) -> str:
|
|
@@ -266,15 +276,17 @@ def _command_rewrite(task_text: str) -> str | None:
|
|
|
266
276
|
|
|
267
277
|
|
|
268
278
|
def _default_rewrite(task_text: str) -> str:
|
|
279
|
+
"""Return the paste path: the four question stems and an order to do the job."""
|
|
280
|
+
numbered_stems = QUESTION_LINE_SEPARATOR.join(
|
|
281
|
+
f"{each_index}. {each_stem}"
|
|
282
|
+
for each_index, each_stem in enumerate(ALL_FOUR_QUESTION_STEMS, start=1)
|
|
283
|
+
)
|
|
269
284
|
return (
|
|
270
285
|
f"{DEEPEN_MARKER}\n\n"
|
|
271
286
|
"Do this task as a leading practitioner in its field would.\n\n"
|
|
272
287
|
f"{task_text}\n\n"
|
|
273
288
|
"Cover four things the original prompt left out:\n"
|
|
274
|
-
"
|
|
275
|
-
"2. The raw source to start from.\n"
|
|
276
|
-
"3. The methods and tools they would use.\n"
|
|
277
|
-
"4. What besides the agent or Jon would show the result is right.\n"
|
|
289
|
+
f"{numbered_stems}\n"
|
|
278
290
|
"Then do exactly that job."
|
|
279
291
|
)
|
|
280
292
|
|
|
@@ -301,11 +313,15 @@ def additional_context_for(rewritten_prompt: str) -> str:
|
|
|
301
313
|
|
|
302
314
|
|
|
303
315
|
def decide_first_prompt(all_hook_fields: dict[str, object]) -> DeepenDecision:
|
|
304
|
-
"""Skip or deepen one UserPromptSubmit payload."""
|
|
316
|
+
"""Skip, refuse, or deepen one UserPromptSubmit payload."""
|
|
305
317
|
reason = skip_reason_for(all_hook_fields)
|
|
306
318
|
if reason is not None:
|
|
307
319
|
return Skip(reason)
|
|
308
|
-
|
|
320
|
+
prompt = submitted_prompt_text(all_hook_fields)
|
|
321
|
+
rewritten_prompt = expand_first_task(prompt)
|
|
322
|
+
refusal = refusal_reason(task_text_for_deepen(prompt), rewritten_prompt)
|
|
323
|
+
if refusal is not None:
|
|
324
|
+
return Refuse(refusal)
|
|
309
325
|
return Deepen(
|
|
310
326
|
rewritten_prompt=rewritten_prompt,
|
|
311
327
|
additional_context=additional_context_for(rewritten_prompt),
|
|
@@ -316,6 +332,10 @@ def _print_skip_json(reason: SkipReason) -> None:
|
|
|
316
332
|
sys.stdout.write(json.dumps({"decision": "skip", "reason": reason.value}) + "\n")
|
|
317
333
|
|
|
318
334
|
|
|
335
|
+
def _print_refuse_json(reason: str) -> None:
|
|
336
|
+
sys.stdout.write(json.dumps({"decision": "refuse", "reason": reason}) + "\n")
|
|
337
|
+
|
|
338
|
+
|
|
319
339
|
def _print_deepen_json(deepen: Deepen) -> None:
|
|
320
340
|
sys.stdout.write(
|
|
321
341
|
json.dumps(
|
|
@@ -364,6 +384,10 @@ def emit_decision(decision: DeepenDecision, all_hook_fields: dict[str, object])
|
|
|
364
384
|
if output_mode == DEEPEN_OUTPUT_JSON:
|
|
365
385
|
_print_skip_json(decision.reason)
|
|
366
386
|
return
|
|
387
|
+
if isinstance(decision, Refuse):
|
|
388
|
+
if output_mode == DEEPEN_OUTPUT_JSON:
|
|
389
|
+
_print_refuse_json(decision.reason)
|
|
390
|
+
return
|
|
367
391
|
write_turn_marker(all_hook_fields)
|
|
368
392
|
if output_mode == DEEPEN_OUTPUT_JSON:
|
|
369
393
|
_print_deepen_json(decision)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Refusal checks for a first-task rewrite.
|
|
2
|
+
|
|
3
|
+
The rewriter lives in ``deepen_first_prompt.py``. This module only scores a
|
|
4
|
+
candidate. A grader imports ``refusal_reason`` and does not rewrite.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from hooks_constants.deepen_first_prompt_constants import (
|
|
12
|
+
ALL_EXAMPLE_NOUN_STOPWORDS,
|
|
13
|
+
ALL_FOUR_QUESTION_STEMS,
|
|
14
|
+
EXAMPLE_NOUN_PATTERN,
|
|
15
|
+
BULLET_PREFIX,
|
|
16
|
+
HARD_CONSTRAINTS_LABEL,
|
|
17
|
+
REFUSAL_DROPPED_HARD_CONSTRAINT,
|
|
18
|
+
REFUSAL_EXAMPLE_NOUN_IN_SCOPE,
|
|
19
|
+
REFUSAL_MISSING_ROLE_LINES,
|
|
20
|
+
REFUSAL_RESTATES_FOUR_QUESTIONS,
|
|
21
|
+
ROLE_BLOCKER_LABEL,
|
|
22
|
+
ROLE_BLOCKER_NONE,
|
|
23
|
+
ROLE_EXAMPLES_LABEL,
|
|
24
|
+
ROLE_SCOPE_LABEL,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _labeled_text(rewritten_prompt: str, label: str) -> str | None:
|
|
29
|
+
"""Return the text after ``label`` on its line, or None when the line is absent."""
|
|
30
|
+
prefix = label
|
|
31
|
+
for each_line in rewritten_prompt.splitlines():
|
|
32
|
+
stripped_line = each_line.strip()
|
|
33
|
+
if stripped_line.startswith(prefix):
|
|
34
|
+
return stripped_line[len(prefix) :].strip()
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _restates_four_questions(rewritten_prompt: str) -> bool:
|
|
39
|
+
"""Return True when the rewrite pastes every canonical question stem."""
|
|
40
|
+
return all(each_stem in rewritten_prompt for each_stem in ALL_FOUR_QUESTION_STEMS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _role_lines_are_missing(rewritten_prompt: str) -> bool:
|
|
44
|
+
"""Return True when any of the three role lines is absent or blank."""
|
|
45
|
+
for each_label in (ROLE_SCOPE_LABEL, ROLE_EXAMPLES_LABEL, ROLE_BLOCKER_LABEL):
|
|
46
|
+
labeled_text = _labeled_text(rewritten_prompt, each_label)
|
|
47
|
+
if labeled_text is None or not labeled_text:
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _example_nouns(examples_text: str) -> frozenset[str]:
|
|
53
|
+
"""Return content words from the examples line. ``none`` yields an empty set."""
|
|
54
|
+
if examples_text.strip().rstrip(".").lower() == ROLE_BLOCKER_NONE:
|
|
55
|
+
return frozenset()
|
|
56
|
+
noun_pattern = re.compile(EXAMPLE_NOUN_PATTERN)
|
|
57
|
+
return frozenset(
|
|
58
|
+
each_match.group(0).lower()
|
|
59
|
+
for each_match in noun_pattern.finditer(examples_text)
|
|
60
|
+
if each_match.group(0).lower() not in ALL_EXAMPLE_NOUN_STOPWORDS
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scope_names_example_noun(scope_text: str, examples_text: str, blocker_text: str) -> bool:
|
|
65
|
+
"""Return True when the scope line names an example noun and the blocker is none."""
|
|
66
|
+
if blocker_text.strip().rstrip(".").lower() != ROLE_BLOCKER_NONE:
|
|
67
|
+
return False
|
|
68
|
+
for each_noun in _example_nouns(examples_text):
|
|
69
|
+
if re.search(
|
|
70
|
+
rf"(?i)(?<![A-Za-z0-9]){re.escape(each_noun)}(?![A-Za-z0-9])",
|
|
71
|
+
scope_text,
|
|
72
|
+
):
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_constraints_heading(stripped_line: str) -> bool:
|
|
78
|
+
"""Return whether the line opens the hard-constraints block."""
|
|
79
|
+
return stripped_line.upper().startswith(HARD_CONSTRAINTS_LABEL)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _bullet_body(stripped_line: str) -> str | None:
|
|
83
|
+
"""Return the text after a bullet marker, or None when the line is not a bullet."""
|
|
84
|
+
if not stripped_line.startswith(BULLET_PREFIX):
|
|
85
|
+
return None
|
|
86
|
+
return stripped_line[len(BULLET_PREFIX) :].strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _constraint_step(
|
|
90
|
+
stripped_line: str,
|
|
91
|
+
collecting: bool,
|
|
92
|
+
has_sentences: bool,
|
|
93
|
+
) -> tuple[str | None, bool, bool]:
|
|
94
|
+
"""Return the sentence to keep, whether collection stays on, and whether to stop."""
|
|
95
|
+
if not collecting:
|
|
96
|
+
return None, _is_constraints_heading(stripped_line), False
|
|
97
|
+
bullet_body = _bullet_body(stripped_line)
|
|
98
|
+
if bullet_body is not None:
|
|
99
|
+
return bullet_body, True, False
|
|
100
|
+
if stripped_line == "" and not has_sentences:
|
|
101
|
+
return None, True, False
|
|
102
|
+
return None, collecting, True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _constraint_sentences(original_task: str) -> tuple[str, ...]:
|
|
106
|
+
"""Return bullet sentences under the hard-constraints heading."""
|
|
107
|
+
collecting = False
|
|
108
|
+
all_sentences: list[str] = []
|
|
109
|
+
for each_line in original_task.splitlines():
|
|
110
|
+
sentence, collecting, finished = _constraint_step(
|
|
111
|
+
each_line.strip(),
|
|
112
|
+
collecting,
|
|
113
|
+
bool(all_sentences),
|
|
114
|
+
)
|
|
115
|
+
if finished:
|
|
116
|
+
break
|
|
117
|
+
if sentence is not None:
|
|
118
|
+
all_sentences.append(sentence)
|
|
119
|
+
return tuple(all_sentences)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _line_body(stripped_line: str) -> str | None:
|
|
123
|
+
"""Return one rewrite line with a leading bullet marker removed."""
|
|
124
|
+
if not stripped_line:
|
|
125
|
+
return None
|
|
126
|
+
bullet_body = _bullet_body(stripped_line)
|
|
127
|
+
if bullet_body is not None:
|
|
128
|
+
return bullet_body
|
|
129
|
+
return stripped_line
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rewrite_line_bodies(rewritten_prompt: str) -> frozenset[str]:
|
|
133
|
+
"""Return each rewrite line with a leading bullet marker removed."""
|
|
134
|
+
all_bodies: set[str] = set()
|
|
135
|
+
for each_line in rewritten_prompt.splitlines():
|
|
136
|
+
body = _line_body(each_line.strip())
|
|
137
|
+
if body is not None:
|
|
138
|
+
all_bodies.add(body)
|
|
139
|
+
return frozenset(all_bodies)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _dropped_hard_constraint(original_task: str, rewritten_prompt: str) -> bool:
|
|
143
|
+
"""Return True when a hard-constraint sentence is absent as its own line."""
|
|
144
|
+
rewrite_bodies = _rewrite_line_bodies(rewritten_prompt)
|
|
145
|
+
return any(
|
|
146
|
+
each_sentence not in rewrite_bodies for each_sentence in _constraint_sentences(original_task)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def refusal_reason(original_task: str, rewritten_prompt: str) -> str | None:
|
|
151
|
+
"""Return the refusal reason for one candidate rewrite, or None when it can ship.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
original_task: The task text the rewrite must keep constraints from.
|
|
155
|
+
rewritten_prompt: The candidate worker prompt.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A refusal reason string, or None when the candidate passes.
|
|
159
|
+
"""
|
|
160
|
+
if _restates_four_questions(rewritten_prompt):
|
|
161
|
+
return REFUSAL_RESTATES_FOUR_QUESTIONS
|
|
162
|
+
if _role_lines_are_missing(rewritten_prompt):
|
|
163
|
+
return REFUSAL_MISSING_ROLE_LINES
|
|
164
|
+
scope_text = _labeled_text(rewritten_prompt, ROLE_SCOPE_LABEL) or ""
|
|
165
|
+
examples_text = _labeled_text(rewritten_prompt, ROLE_EXAMPLES_LABEL) or ""
|
|
166
|
+
blocker_text = _labeled_text(rewritten_prompt, ROLE_BLOCKER_LABEL) or ""
|
|
167
|
+
if _scope_names_example_noun(scope_text, examples_text, blocker_text):
|
|
168
|
+
return REFUSAL_EXAMPLE_NOUN_IN_SCOPE
|
|
169
|
+
if _dropped_hard_constraint(original_task, rewritten_prompt):
|
|
170
|
+
return REFUSAL_DROPPED_HARD_CONSTRAINT
|
|
171
|
+
return None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"task": "Keep the build green. For example, the login failure.\n\nHARD CONSTRAINTS\n- Never open a pull request.\n",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"name": "known_bad",
|
|
6
|
+
"expect": "refuse",
|
|
7
|
+
"rewrittenPrompt": "CDE_DEEPEN:v1\nSCOPE (role): Fix the login failure\nEXAMPLES (illustration only): login failure\nZOOM-IN BLOCKER: none\n- Never open a pull request.\nRead the workflow log and repair the shared gate.\n"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "known_good",
|
|
11
|
+
"expect": "accept",
|
|
12
|
+
"rewrittenPrompt": "CDE_DEEPEN:v1\nSCOPE (role): Keep every build green across the repository\nEXAMPLES (illustration only): login failure\nZOOM-IN BLOCKER: none\n- Never open a pull request.\nRead the workflow log and repair the shared gate.\n"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Score a replay fixture. This script does not rewrite the task."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
11
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
12
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
13
|
+
if each_sys_path_entry not in sys.path:
|
|
14
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
15
|
+
|
|
16
|
+
from deepen_role_gate import refusal_reason
|
|
17
|
+
from hooks_constants.deepen_first_prompt_constants import (
|
|
18
|
+
SCORE_ACCEPT,
|
|
19
|
+
SCORE_REFUSE,
|
|
20
|
+
UTF8_ENCODING,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def score_replay(task_text: str, rewritten_prompt: str) -> dict[str, str]:
|
|
25
|
+
"""Return accept, or refuse plus the gate reason. Never rewrite the task."""
|
|
26
|
+
reason = refusal_reason(task_text, rewritten_prompt)
|
|
27
|
+
if reason is None:
|
|
28
|
+
return {"score": SCORE_ACCEPT}
|
|
29
|
+
return {"score": SCORE_REFUSE, "reason": reason}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def score_fixture(all_replay_fields: dict[str, object]) -> list[dict[str, str]]:
|
|
33
|
+
"""Score each case in a replay fixture against the task it names."""
|
|
34
|
+
task_text = all_replay_fields["task"]
|
|
35
|
+
if not isinstance(task_text, str):
|
|
36
|
+
raise TypeError("fixture task must be a string")
|
|
37
|
+
cases = all_replay_fields["cases"]
|
|
38
|
+
if not isinstance(cases, list):
|
|
39
|
+
raise TypeError("fixture cases must be a list")
|
|
40
|
+
all_scores: list[dict[str, str]] = []
|
|
41
|
+
for each_case in cases:
|
|
42
|
+
if not isinstance(each_case, dict):
|
|
43
|
+
raise TypeError("each fixture case must be an object")
|
|
44
|
+
case_name = each_case["name"]
|
|
45
|
+
rewritten_prompt = each_case["rewrittenPrompt"]
|
|
46
|
+
expected_score = each_case["expect"]
|
|
47
|
+
if not isinstance(case_name, str) or not isinstance(rewritten_prompt, str):
|
|
48
|
+
raise TypeError("case name and rewrittenPrompt must be strings")
|
|
49
|
+
if not isinstance(expected_score, str):
|
|
50
|
+
raise TypeError("case expect must be a string")
|
|
51
|
+
scored = score_replay(task_text, rewritten_prompt)
|
|
52
|
+
scored["name"] = case_name
|
|
53
|
+
scored["expect"] = expected_score
|
|
54
|
+
scored["matched"] = "yes" if scored["score"] == expected_score else "no"
|
|
55
|
+
all_scores.append(scored)
|
|
56
|
+
return all_scores
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(all_arguments: list[str]) -> int:
|
|
60
|
+
"""Read one fixture path and print a score line per case."""
|
|
61
|
+
if len(all_arguments) != 1:
|
|
62
|
+
sys.stderr.write("usage: grade_deepstack_replay.py <fixture.json>\n")
|
|
63
|
+
return 2
|
|
64
|
+
fixture_path = Path(all_arguments[0])
|
|
65
|
+
fixture = json.loads(fixture_path.read_text(encoding=UTF8_ENCODING))
|
|
66
|
+
if not isinstance(fixture, dict):
|
|
67
|
+
sys.stderr.write("fixture root must be an object\n")
|
|
68
|
+
return 2
|
|
69
|
+
all_scores = score_fixture(fixture)
|
|
70
|
+
for each_score in all_scores:
|
|
71
|
+
sys.stdout.write(json.dumps(each_score) + "\n")
|
|
72
|
+
if any(each_score["matched"] != "yes" for each_score in all_scores):
|
|
73
|
+
return 1
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
raise SystemExit(main(sys.argv[1:]))
|