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.
Files changed (44) hide show
  1. package/.agents/skills/deepen/SKILL.md +5 -21
  2. package/.agents/skills/efficient-review/README.md +44 -0
  3. package/.agents/skills/efficient-review/SKILL.md +14 -61
  4. package/.agents/skills/efficient-review/reference/criteria.md +21 -0
  5. package/.agents/skills/efficient-review/tools/__init__.py +1 -0
  6. package/.agents/skills/efficient-review/tools/config/__init__.py +1 -0
  7. package/.agents/skills/efficient-review/tools/config/efficient_review_constants.py +28 -0
  8. package/.agents/skills/efficient-review/tools/efficient_review.py +34 -0
  9. package/.agents/skills/efficient-review/tools/efficient_review_plan.py +95 -0
  10. package/.agents/skills/efficient-review/tools/tests/test_review_plan.py +94 -0
  11. package/.agents/skills/search-before-acting/SKILL.md +4 -3
  12. package/.agents/skills/search-before-acting/playbooks/build.md +1 -1
  13. package/.agents/skills/search-before-acting/playbooks/plan-or-log.md +1 -1
  14. package/.agents/skills/search-before-acting/references/report-shape.md +1 -1
  15. package/bin/codex-skill-load-block.mjs +4 -4
  16. package/bin/install.prune.test.mjs +10 -4
  17. package/commands/deepen.md +3 -8
  18. package/docs/account-broker.md +2 -2
  19. package/docs/codex-accounts.md +1 -1
  20. package/docs/decisions/deepstack-gate.md +28 -0
  21. package/docs/rule-guides/agent-board.md +2 -2
  22. package/docs/rule-guides/explore-thoroughly.md +2 -2
  23. package/docs/rule-guides/features-start-with-an-eval.md +2 -2
  24. package/docs/second-claude-account.md +1 -1
  25. package/hooks/features/session-context.md +1 -1
  26. package/hooks/hooks_constants/deepen_first_prompt_constants.py +60 -0
  27. package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +47 -7
  28. package/hooks/session/deepen_first_prompt.py +31 -7
  29. package/hooks/session/deepen_role_gate.py +171 -0
  30. package/hooks/session/fixtures/deepstack_replay.json +15 -0
  31. package/hooks/session/grade_deepstack_replay.py +78 -0
  32. package/hooks/session/test_deepen_first_prompt.py +127 -23
  33. package/hooks/session/test_grade_deepstack_replay.py +47 -0
  34. package/package.json +1 -1
  35. package/rules/agent-board.md +1 -1
  36. package/rules/index.md +2 -2
  37. package/scripts/account_broker.py +1 -1
  38. package/scripts/account_broker_support.py +3 -3
  39. package/scripts/ci/install-playtest.mjs +3 -0
  40. package/scripts/codex_account_choice.py +5 -5
  41. package/scripts/dev_env_scripts_constants/codex_account_constants.py +3 -3
  42. package/scripts/test_codex_account_choice.py +1 -1
  43. package/.agents/skills/efficient-review/defaults.json +0 -12
  44. 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 agent-channels repository 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`.
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 agent-channels repository that your project instructions name. Attach it with the `mcp__claude-code-remote__add_repo` tool if the session lacks it.
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, stop and report it to the user in four points; run the `search-before-acting` skill. Then read files, patterns, and constraints, scaled to risk. Once the files, constraints, and success condition are known, act without repeating settled research.
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, stop before you act and report what exists and where, what it covers of the ask, what is missing, and what the addition would add. When it covers the whole ask, tell the user they already have it. When the search finds nothing, say where you looked and continue.
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
- Your first action is the Skill tool with skill `claude-api` and args `build-eval`. 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.
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. 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.
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 roster and usage meters, chooses an account, and runs Claude with that account's home |
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 names the four omitted answers.
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 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 and "
20
- "CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 in the environment, then run this from Bash or another POSIX shell, "
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
- "claude -p --model haiku --setting-sources local "
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, /efficient-review headless or /efficient-review auto builds "
28
- "this profile. To keep the agent in this session, spawn it on opus or sonnet."
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
- DeepenDecision = Skip | Deepen
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
- "1. What a top person in that field would do that this prompt omitted.\n"
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
- rewritten_prompt = expand_first_task(submitted_prompt_text(all_hook_fields))
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:]))