claude-dev-env 8.103.1 → 8.105.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.
@@ -0,0 +1,41 @@
1
+ """The verifier agent carries the verified-work contract: three modes, reproduce first, and no pass without a reproduction."""
2
+
3
+ from pathlib import Path
4
+
5
+ VERIFIER_PATH = Path(__file__).resolve().parent / "verifier.md"
6
+
7
+
8
+ def _verifier_text() -> str:
9
+ return VERIFIER_PATH.read_text(encoding="utf-8")
10
+
11
+
12
+ def test_the_verifier_names_a_mode_for_research_plan_and_fix() -> None:
13
+ verifier_text = _verifier_text()
14
+ assert "**Research.**" in verifier_text
15
+ assert "CONFIRMED or REFUTED" in verifier_text
16
+ assert "**Plan.**" in verifier_text
17
+ assert "SOUND or GAP" in verifier_text
18
+ assert "**Fix.**" in verifier_text
19
+
20
+
21
+ def test_the_fix_mode_reproduces_on_the_old_state_before_it_confirms_on_the_new() -> None:
22
+ verifier_text = _verifier_text()
23
+ reproduce_position = verifier_text.index("1. Reproduce. On the old state")
24
+ confirm_position = verifier_text.index("2. Confirm. On the new state")
25
+ undo_position = verifier_text.index("3. Undo.")
26
+ assert reproduce_position < confirm_position < undo_position
27
+
28
+
29
+ def test_a_fix_whose_issue_did_not_reproduce_never_passes() -> None:
30
+ assert "NOT REPRODUCED is never a pass" in _verifier_text()
31
+
32
+
33
+ def test_the_verifier_leaves_the_work_under_review_untouched() -> None:
34
+ assert "Do not edit, create, or delete any file in the work under review" in _verifier_text()
35
+
36
+
37
+ def test_the_brief_template_carries_the_spawn_oversight_clauses() -> None:
38
+ verifier_text = _verifier_text()
39
+ assert "Outcome: <" in verifier_text
40
+ assert "On a blocker: <" in verifier_text
41
+ assert "leaves out the worker's conclusion" in verifier_text
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: verifier
3
+ description: Separate checker for the verified-work flow. Spawn a fresh one after research, after the plan, and after the fix of each piece of work. It never checks work it did. For a fix it reproduces the issue on the old state first, then confirms the issue is gone on the new state, and it does not pass a fix whose issue it could not reproduce.
4
+ tools: Read, Glob, Grep, Bash, PowerShell
5
+ ---
6
+
7
+ You check work that another agent did. Read the brief, then run the mode it names. Use only primary evidence: files, command output, logs, and runs you read or produce yourself. A claim in the brief is a claim to test, never evidence.
8
+
9
+ Your check ends the chain: it needs no research, plan, or verifier of its own.
10
+
11
+ Do not edit, create, or delete any file in the work under review. Put probes and scratch files outside it, and name each one in the report.
12
+
13
+ ## Modes
14
+
15
+ **Research.** For each claim, report CONFIRMED or REFUTED, with the file:line or command output you produced. Then list what the research missed that bears on the issue.
16
+
17
+ **Plan.** For each piece, report SOUND or GAP. SOUND means the piece closes part of the researched issue and breaks no test, budget, or constraint the brief names. Check each scope tag against the user's own words. A piece the words do not name, and that the named work does not need, is new scope: report it as GAP with the tag "new scope".
18
+
19
+ **Fix.** Run these steps in order.
20
+
21
+ 1. Reproduce. On the old state, run the check that shows the issue. Report REPRODUCED with the output, or NOT REPRODUCED.
22
+ 2. Confirm. On the new state, run the same check. Report FIXED with the output, or NOT FIXED.
23
+ 3. Undo. Revert the fix in a scratch copy and run the check again. It must fail again. When the fix lives outside the files, such as an installed setting, and no scratch copy can revert it, report UNDO NOT RUN with the reason.
24
+
25
+ A fix passes only with REPRODUCED, FIXED, and an undo that fails again or UNDO NOT RUN with its reason. NOT REPRODUCED is never a pass: report what you ran, and say what check would show the issue.
26
+
27
+ ## Report
28
+
29
+ End with one line per item: the item, the verdict, and the evidence. Then one overall line: PASS or FAIL.
30
+
31
+ ## Brief template
32
+
33
+ The agent that spawns you writes the brief with these lines:
34
+
35
+ ```text
36
+ Mode: research | plan | fix
37
+ Outcome: <the user's outcome, in the user's words>
38
+ Issue: <the issue, with the source that shows it>
39
+ Sources: <files, commands, the old state and the new state>
40
+ On a blocker: <the other routes to the same evidence>
41
+ ```
42
+
43
+ The brief gives the issue and the sources. It leaves out the worker's conclusion.
@@ -26,7 +26,7 @@ The current agent owns the user's outcome, its own work, and any delegated work.
26
26
  Keep the goal, decisions, ownership, and next actions in the parent context.
27
27
  Put bulk research, logs, and implementation detail in bounded worker contexts and linked artifacts.
28
28
  Do short work inline when that keeps the task clear.
29
- This session is the advisor for its executors and verifies their returned claims.
29
+ This session is the advisor for its executors. A fresh `verifier` agent checks each returned claim, per `~/.claude/docs/rule-guides/verified-work.md`.
30
30
 
31
31
  ## Gotchas
32
32
 
@@ -105,7 +105,7 @@ Send follow-ups only through a transport authorized by the current runtime and u
105
105
  Read the evidence needed for your decision. Keep lengthy output in files and return short evidence pointers.
106
106
  Check scope, acceptance results, and unresolved work before accepting a worker's conclusion.
107
107
  Review every close a worker reports. Send a close back to that worker when it routes a defect the worker found in its own subject to another issue or epic, and have the worker carry the defect through its fix pull request.
108
- Use independent verification where the task or repository requires it.
108
+ Run every piece through the verified-work flow: a fresh `verifier` agent checks the research, the plan, and the fix, and the fix check reproduces the issue before it confirms the fix.
109
109
  Checkpoint after decisions and state changes, before waiting, and before a known compaction.
110
110
 
111
111
  ### Complete the requested outcome
@@ -3,10 +3,11 @@
3
3
  Use this procedure before a completion report or an answer about whether work is done.
4
4
 
5
5
  1. Recover the user's requested outcome and acceptance conditions. Read the evidence for that outcome before choosing its status.
6
- 2. Check what each unfinished item changes. Missing deliverables, required verification, necessary approvals, and stated completion prerequisites keep the affected goal open. An administrative item whose absence has no effect on the result, its use, or its acceptance keeps its own task open.
7
- 3. State the main outcome first. When that outcome is verified complete and only incidental recordkeeping remains, report the task as complete. A requested status notice alone does not make the main outcome incomplete. Mention the administrative item separately when useful or required by the active reporting rules.
8
- 4. Keep unfinished work tracked honestly. When one tracker item combines the completed outcome and unfinished administration, preserve the outcome's goal and evidence, mark its work complete, and keep a separate open administrative follow-up. Do reachable administrative work before ending. Preserve its next action or blocker when it cannot be finished. Never mark it done merely to make the report look complete.
9
- 5. Preserve other goals and required delivery. A complete subgoal does not close an unfinished goal or the parent run. Use the goal's acceptance conditions in the [run record](run-state.md) to decide what its remaining work affects.
6
+ 2. Confirm that a fresh `verifier` agent passed the result, per `~/.claude/docs/rule-guides/verified-work.md`. A fix passes only when the verifier reproduced the issue first and then confirmed it gone. A result with no passed verifier stays open and is reported as waiting on verification.
7
+ 3. Check what each unfinished item changes. Missing deliverables, required verification, necessary approvals, and stated completion prerequisites keep the affected goal open. An administrative item whose absence has no effect on the result, its use, or its acceptance keeps its own task open.
8
+ 4. State the main outcome first. When that outcome is verified complete and only incidental recordkeeping remains, report the task as complete. A requested status notice alone does not make the main outcome incomplete. Mention the administrative item separately when useful or required by the active reporting rules.
9
+ 5. Keep unfinished work tracked honestly. When one tracker item combines the completed outcome and unfinished administration, preserve the outcome's goal and evidence, mark its work complete, and keep a separate open administrative follow-up. Do reachable administrative work before ending. Preserve its next action or blocker when it cannot be finished. Never mark it done merely to make the report look complete.
10
+ 6. Preserve other goals and required delivery. A complete subgoal does not close an unfinished goal or the parent run. Use the goal's acceptance conditions in the [run record](run-state.md) to decide what its remaining work affects.
10
11
 
11
12
  An administrative action can be requested without being a condition of the main outcome's completion. Treat it as a completion prerequisite when the user states that dependency, when it carries an essential requested result, or when its absence affects the result's usability, safety, or completeness.
12
13
 
@@ -1,13 +1,15 @@
1
1
  # Oversee delegated work
2
2
 
3
3
  Every agent that spawns a subagent, thread, or session owns that work until it reaches the user.
4
- Agents make mistakes. The spawner is a fresh second set of eyes that catches and corrects them before they reach the user.
4
+ Agents make mistakes. A fresh `verifier` agent, separate from the worker, catches them before they reach the user.
5
5
  The `spawn_oversight_hook` adds this duty to every spawn, at every level of the tree.
6
6
 
7
- - Brief each agent with the user's own words, the standards that apply, and the acceptance check.
8
- - While it runs, read its progress. Wake a quiet agent with one concrete next step.
9
- - Before its output reaches the user, check it against the user's words and standards yourself.
10
- - Send the agent a correction when it misses, and check the corrected output the same way.
11
- - Give each piece of a multi-piece task its own reviewer or helper.
7
+ - Run each piece through the verified-work flow in `~/.claude/docs/rule-guides/verified-work.md`: research, verify, plan, verify, implement, verify.
8
+ - Brief each worker with the user's own words, the standards that apply, and the acceptance check.
9
+ - Brief each verifier with the issue, the sources, the old state, and the new state. Leave the worker's conclusion out.
10
+ - While a worker runs, read its progress. Wake a quiet agent with one concrete next step.
11
+ - A fix passes only when its verifier reproduced the issue on the old state and confirmed it gone on the new state.
12
+ - Send a FAIL back to the worker with the verifier's evidence, and give the next attempt a new verifier.
13
+ - Tag each piece in-scope or new scope. Ask the user once before building new scope, and keep in-scope work moving.
12
14
  - Keep each brief, prompt, and context file you hand an agent a short map: when to use it, the steps, and pointers to reference files it opens on demand.
13
- - Show the user you are involved: say what you checked and what you corrected.
15
+ - Report as done only what a verifier passed, and name the verdict and its evidence. Report the rest as waiting on verification.
@@ -58,3 +58,30 @@ def test_executor_consult_block_names_orchestrating_session() -> None:
58
58
  )
59
59
  assert "<orchestrator-name>" in text
60
60
  assert "your advisor" in text
61
+
62
+
63
+ _OVERSEE_PATH = _SKILL_DIR / "reference" / "oversee-delegated-work.md"
64
+ _COMPLETION_PATH = _SKILL_DIR / "reference" / "completion-reporting.md"
65
+ _VERIFIED_WORK_GUIDE = "~/.claude/docs/rule-guides/verified-work.md"
66
+
67
+
68
+ def test_independent_verification_is_no_longer_conditional() -> None:
69
+ text = (_SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
70
+ assert "Use independent verification where the task or repository requires it." not in text
71
+ assert "Run every piece through the verified-work flow" in text
72
+ assert _VERIFIED_WORK_GUIDE in text
73
+
74
+
75
+ def test_a_separate_verifier_checks_delegated_work() -> None:
76
+ text = _OVERSEE_PATH.read_text(encoding="utf-8")
77
+ assert "check it against the user's words and standards yourself" not in text
78
+ assert "A fresh `verifier` agent, separate from the worker" in text
79
+ assert "Leave the worker's conclusion out." in text
80
+ assert "reproduced the issue on the old state and confirmed it gone on the new state" in text
81
+ assert "Ask the user once before building new scope" in text
82
+
83
+
84
+ def test_completion_needs_a_passed_verifier() -> None:
85
+ text = _COMPLETION_PATH.read_text(encoding="utf-8")
86
+ assert "Confirm that a fresh `verifier` agent passed the result" in text
87
+ assert "reported as waiting on verification" in text
@@ -0,0 +1,51 @@
1
+ Back to the [rules index](../../rules/index.md).
2
+
3
+ # Verified work
4
+
5
+ ## In brief
6
+
7
+ Every piece of work runs in six steps: research, verify the research, plan, verify the plan, implement, verify the fix. A separate `verifier` agent runs each verify step. The fix check reproduces the issue on the old state first, then confirms it is gone on the new state. The user gets only results a verifier passed. New scope goes to the user as one question before it is built.
8
+
9
+ **When this applies:** Any task: a change to code, configuration, rules, or a document, and any finding, cause, or fix reported to the user.
10
+
11
+ ## The six steps
12
+
13
+ 1. **Research.** Find the issue and its cause in primary sources: files, logs, runs, command output. Write each finding as a claim with its source.
14
+ 2. **Verify the research.** Spawn a fresh `verifier` in research mode. It reports CONFIRMED or REFUTED for each claim, and what the research missed. Fix the research before you plan.
15
+ 3. **Plan.** List the pieces of the fix. Tag each piece in-scope or new scope (see [Scope](#scope)).
16
+ 4. **Verify the plan.** Spawn a fresh `verifier` in plan mode. It reports SOUND or GAP for each piece: does it close part of the researched issue, and does it break a test, a budget, or a constraint. It checks each scope tag against the user's words. Revise the plan for each GAP.
17
+ 5. **Implement.** Build the in-scope pieces.
18
+ 6. **Verify the fix.** Spawn a fresh `verifier` in fix mode. It reproduces the issue on the old state, confirms the issue is gone on the new state, and confirms an undo of the fix fails again. When no scratch copy can revert the fix, such as an installed setting, it reports UNDO NOT RUN with the reason, and REPRODUCED plus FIXED still pass. A fix whose issue did not reproduce does not pass.
19
+
20
+ ## The verifier
21
+
22
+ - The verifier is never the agent that did the work. The spawner is the owner of the work. A fresh agent at each verify step keeps the check apart from the author.
23
+ - Its brief gives the issue, the sources, the old state and the new state. It leaves out the worker's conclusion, so the verifier tests the claim and does not inherit it.
24
+ - Use the `verifier` agent definition and its brief template. On a host without that agent, give a fresh general-purpose agent the same contract.
25
+ - A verifier's own check ends the chain. It runs no research, plan, or verifier of its own.
26
+ - A FAIL goes back to the worker with the verifier's evidence. After the next attempt, a new verifier checks it.
27
+
28
+ ## Scope
29
+
30
+ A piece is in-scope when it traces to the user's own words, or when the named work needs it to be done, such as a test for the fix, a doc row that the change makes stale, or a refactor the fix cannot land without. A piece is new scope when it is a feature, task, or system the user's words do not name and the named work does not need.
31
+
32
+ - In-scope work runs with no ask.
33
+ - New scope goes to the user as one question before any of it is built: what it is, why the work led to it, and what it costs. Use the session's question tool, per [`question-presentation.md`](question-presentation.md).
34
+ - While the question waits, keep building the in-scope pieces, per [`long-horizon-autonomy.md`](long-horizon-autonomy.md).
35
+
36
+ Examples, labelled as a format guide:
37
+
38
+ | Ask | Piece | Tag |
39
+ |-|-|-|
40
+ | Fix the sign-in step that fails in cloud sessions | Repair the token refresh the sign-in calls | in-scope |
41
+ | Fix the sign-in step that fails in cloud sessions | A lock and write-back system for shared credentials | new scope: ask first |
42
+ | Add a flag that skips the cache | Update the README table that lists the flags | in-scope |
43
+ | Add a flag that skips the cache | Rewrite the cache as a separate service | new scope: ask first |
44
+
45
+ ## Reporting
46
+
47
+ Report only what a verifier passed. Name the verifier's verdict and the evidence it produced. A result that no verifier has passed is still work in progress: say that it waits on verification.
48
+
49
+ ## Enforcement
50
+
51
+ `hooks/routing/spawn_oversight_hook.py` adds the six steps, the separate verifier, the reproduce-first check, the scope ask, and the reporting rule to every spawn at every level of the agent tree whose brief has an `Outcome:` line and an `On a blocker:` line; the hook already denies a brief without them, and the verifier brief template carries both. The flow itself adds context only. No hook refuses a report, a commit, or a spawn for a missing verifier run, so the flow never blocks work the user asked for.
@@ -0,0 +1,280 @@
1
+ import io
2
+ import json
3
+ from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import wakeup_cap_gate
9
+
10
+ SEND_LATER_TOOL = "mcp__claude-code-remote__send_later"
11
+ CREATE_TRIGGER_TOOL = "mcp__claude-code-remote__create_trigger"
12
+ UPDATE_TRIGGER_TOOL = "mcp__claude-code-remote__update_trigger"
13
+
14
+
15
+ def run_gate(
16
+ monkeypatch: pytest.MonkeyPatch,
17
+ capsys: pytest.CaptureFixture[str],
18
+ tool_name: str,
19
+ tool_input: dict[str, object],
20
+ ) -> tuple[int, str, str]:
21
+ hook_input = {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
22
+ monkeypatch.setattr(
23
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
24
+ )
25
+ exit_code = wakeup_cap_gate.main()
26
+ captured = capsys.readouterr()
27
+ return exit_code, captured.out, captured.err
28
+
29
+
30
+ def updated_input_of(stdout_text: str) -> dict[str, object]:
31
+ payload = json.loads(stdout_text)
32
+ hook_output = payload["hookSpecificOutput"]
33
+ assert hook_output["permissionDecision"] == "allow"
34
+ return hook_output["updatedInput"]
35
+
36
+
37
+ def utc_text(moment: datetime) -> str:
38
+ return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
39
+
40
+
41
+ def minutes_from_now(minutes: float) -> datetime:
42
+ return datetime.now(timezone.utc) + timedelta(minutes=minutes)
43
+
44
+
45
+ @pytest.fixture(autouse=True)
46
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
47
+ monkeypatch.setenv("HOME", str(tmp_path))
48
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
49
+
50
+
51
+ @pytest.mark.parametrize("requested_minutes", [58, 59, 60])
52
+ def test_send_later_at_the_cache_edge_is_rewritten_to_the_step_down(
53
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], requested_minutes: int
54
+ ) -> None:
55
+ tool_input = {"delay_minutes": requested_minutes, "message": "check in", "name": "probe"}
56
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, SEND_LATER_TOOL, tool_input)
57
+ assert exit_code == 0
58
+ assert updated_input_of(stdout_text) == {
59
+ "delay_minutes": 57,
60
+ "message": "check in",
61
+ "name": "probe",
62
+ }
63
+
64
+
65
+ @pytest.mark.parametrize("requested_minutes", [1, 45, 57])
66
+ def test_send_later_under_the_step_down_passes_untouched(
67
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], requested_minutes: int
68
+ ) -> None:
69
+ exit_code, stdout_text, stderr_text = run_gate(
70
+ monkeypatch, capsys, SEND_LATER_TOOL, {"delay_minutes": requested_minutes, "message": "x"}
71
+ )
72
+ assert (exit_code, stdout_text, stderr_text) == (0, "", "")
73
+
74
+
75
+ def test_send_later_over_an_hour_is_refused_with_the_stacking_instruction(
76
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
77
+ ) -> None:
78
+ exit_code, stdout_text, stderr_text = run_gate(
79
+ monkeypatch, capsys, SEND_LATER_TOOL, {"delay_minutes": 90, "message": "x"}
80
+ )
81
+ assert exit_code == 2
82
+ assert stdout_text == ""
83
+ assert "delay_minutes=57" in stderr_text
84
+ assert "delaySeconds=3480" in stderr_text
85
+ assert "stack" in stderr_text
86
+
87
+
88
+ def test_send_later_at_inside_the_cache_edge_is_rewritten(
89
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
90
+ ) -> None:
91
+ exit_code, stdout_text, _ = run_gate(
92
+ monkeypatch,
93
+ capsys,
94
+ SEND_LATER_TOOL,
95
+ {"at": utc_text(minutes_from_now(59.5)), "message": "x"},
96
+ )
97
+ assert exit_code == 0
98
+ rewritten_at = datetime.fromisoformat(
99
+ str(updated_input_of(stdout_text)["at"]).replace("Z", "+00:00")
100
+ )
101
+ assert rewritten_at <= minutes_from_now(57)
102
+ assert rewritten_at >= minutes_from_now(56)
103
+
104
+
105
+ def test_send_later_at_two_hours_out_is_refused(
106
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
107
+ ) -> None:
108
+ exit_code, _, stderr_text = run_gate(
109
+ monkeypatch,
110
+ capsys,
111
+ SEND_LATER_TOOL,
112
+ {"at": utc_text(minutes_from_now(120)), "message": "x"},
113
+ )
114
+ assert exit_code == 2
115
+ assert "stack" in stderr_text
116
+
117
+
118
+ @pytest.mark.parametrize(("requested_seconds", "allowed_seconds"), [(3500, 3480), (3600, 3480)])
119
+ def test_schedule_wakeup_at_the_cache_edge_is_rewritten(
120
+ monkeypatch: pytest.MonkeyPatch,
121
+ capsys: pytest.CaptureFixture[str],
122
+ requested_seconds: int,
123
+ allowed_seconds: int,
124
+ ) -> None:
125
+ tool_input = {"delaySeconds": requested_seconds, "prompt": "/loop tick", "reason": "r"}
126
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, "ScheduleWakeup", tool_input)
127
+ assert exit_code == 0
128
+ assert updated_input_of(stdout_text)["delaySeconds"] == allowed_seconds
129
+ assert updated_input_of(stdout_text)["prompt"] == "/loop tick"
130
+
131
+
132
+ @pytest.mark.parametrize("requested_seconds", [60, 1800, 3480])
133
+ def test_schedule_wakeup_under_the_step_down_passes(
134
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], requested_seconds: int
135
+ ) -> None:
136
+ exit_code, stdout_text, _ = run_gate(
137
+ monkeypatch, capsys, "ScheduleWakeup", {"delaySeconds": requested_seconds, "prompt": "p"}
138
+ )
139
+ assert (exit_code, stdout_text) == (0, "")
140
+
141
+
142
+ def test_schedule_wakeup_over_an_hour_is_refused(
143
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
144
+ ) -> None:
145
+ exit_code, _, stderr_text = run_gate(
146
+ monkeypatch, capsys, "ScheduleWakeup", {"delaySeconds": 5400, "prompt": "p"}
147
+ )
148
+ assert exit_code == 2
149
+ assert "delaySeconds=3480" in stderr_text
150
+
151
+
152
+ def test_create_trigger_run_once_at_the_hour_is_rewritten(
153
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
154
+ ) -> None:
155
+ tool_input = {
156
+ "name": "n",
157
+ "prompt": "p",
158
+ "initiation": "own_followup",
159
+ "run_once_at": utc_text(minutes_from_now(60)),
160
+ }
161
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, CREATE_TRIGGER_TOOL, tool_input)
162
+ assert exit_code == 0
163
+ rewritten_at = datetime.fromisoformat(
164
+ str(updated_input_of(stdout_text)["run_once_at"]).replace("Z", "+00:00")
165
+ )
166
+ assert rewritten_at <= minutes_from_now(57)
167
+
168
+
169
+ @pytest.mark.parametrize(
170
+ "cron_expression", ["0 * * * *", "0 */4 * * *", "CRON_TZ=America/Los_Angeles 37 8 * * 1-5"]
171
+ )
172
+ def test_create_trigger_cron_into_a_session_is_refused(
173
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], cron_expression: str
174
+ ) -> None:
175
+ tool_input = {
176
+ "name": "n",
177
+ "prompt": "p",
178
+ "initiation": "own_followup",
179
+ "cron_expression": cron_expression,
180
+ }
181
+ exit_code, _, stderr_text = run_gate(monkeypatch, capsys, CREATE_TRIGGER_TOOL, tool_input)
182
+ assert exit_code == 2
183
+ assert cron_expression in stderr_text
184
+
185
+
186
+ def test_create_trigger_with_a_fresh_session_per_firing_passes(
187
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
188
+ ) -> None:
189
+ tool_input = {
190
+ "name": "n",
191
+ "prompt": "p",
192
+ "initiation": "human_request",
193
+ "cron_expression": "0 9 * * *",
194
+ "create_new_session_on_fire": True,
195
+ }
196
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, CREATE_TRIGGER_TOOL, tool_input)
197
+ assert (exit_code, stdout_text) == (0, "")
198
+
199
+
200
+ def test_update_trigger_run_once_at_over_an_hour_is_refused(
201
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
202
+ ) -> None:
203
+ tool_input = {"trigger_id": "trig_x", "run_once_at": utc_text(minutes_from_now(90))}
204
+ exit_code, _, _ = run_gate(monkeypatch, capsys, UPDATE_TRIGGER_TOOL, tool_input)
205
+ assert exit_code == 2
206
+
207
+
208
+ def test_update_trigger_cron_change_passes(
209
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
210
+ ) -> None:
211
+ tool_input = {"trigger_id": "trig_x", "cron_expression": "0 9 * * *"}
212
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, UPDATE_TRIGGER_TOOL, tool_input)
213
+ assert (exit_code, stdout_text) == (0, "")
214
+
215
+
216
+ @pytest.mark.parametrize(
217
+ "cron_expression",
218
+ ["*/5 * * * *", "*/30 * * * *", "7,37 * * * *", "*/20 0-23 1-31 1-12 0-7", "*/20 * * * 0-6"],
219
+ )
220
+ def test_cron_create_firing_more_often_than_the_step_down_passes(
221
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], cron_expression: str
222
+ ) -> None:
223
+ exit_code, stdout_text, _ = run_gate(
224
+ monkeypatch, capsys, "CronCreate", {"cron": cron_expression, "prompt": "p"}
225
+ )
226
+ assert (exit_code, stdout_text) == (0, "")
227
+
228
+
229
+ @pytest.mark.parametrize(
230
+ "cron_expression",
231
+ ["7 * * * *", "57 8 * * *", "0,58 * * * *", "*/20 9-17 * * *", "*/5 * * * 1-5", "*/5 * * * MON"],
232
+ )
233
+ def test_cron_create_with_an_hour_or_more_between_wakes_is_refused(
234
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], cron_expression: str
235
+ ) -> None:
236
+ exit_code, _, stderr_text = run_gate(
237
+ monkeypatch, capsys, "CronCreate", {"cron": cron_expression, "prompt": "p"}
238
+ )
239
+ assert exit_code == 2
240
+ assert "stack" in stderr_text
241
+
242
+
243
+ def pinned_local_cron(minutes_ahead: float) -> str:
244
+ fire_time = datetime.now().astimezone() + timedelta(minutes=minutes_ahead)
245
+ return f"{fire_time.minute} {fire_time.hour} {fire_time.day} {fire_time.month} *"
246
+
247
+
248
+ def test_cron_create_one_shot_within_the_step_down_passes(
249
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
250
+ ) -> None:
251
+ tool_input = {"cron": pinned_local_cron(30), "prompt": "p", "recurring": False}
252
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, "CronCreate", tool_input)
253
+ assert (exit_code, stdout_text) == (0, "")
254
+
255
+
256
+ def test_cron_create_one_shot_with_a_timezone_prefix_is_refused(
257
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
258
+ ) -> None:
259
+ tool_input = {
260
+ "cron": "CRON_TZ=America/Los_Angeles " + pinned_local_cron(30),
261
+ "prompt": "p",
262
+ "recurring": False,
263
+ }
264
+ exit_code, _, _ = run_gate(monkeypatch, capsys, "CronCreate", tool_input)
265
+ assert exit_code == 2
266
+
267
+
268
+ def test_cron_create_one_shot_two_hours_out_is_refused(
269
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
270
+ ) -> None:
271
+ tool_input = {"cron": pinned_local_cron(120), "prompt": "p", "recurring": False}
272
+ exit_code, _, _ = run_gate(monkeypatch, capsys, "CronCreate", tool_input)
273
+ assert exit_code == 2
274
+
275
+
276
+ def test_other_tools_pass(
277
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
278
+ ) -> None:
279
+ exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, "Bash", {"command": "sleep 4000"})
280
+ assert (exit_code, stdout_text) == (0, "")
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that keeps every self-wakeup inside the 1-hour prompt cache.
3
+
4
+ A session that wakes 60 minutes or more after its last turn resumes with a cold
5
+ prompt cache and re-reads its whole transcript. The gate reads each tool call
6
+ that schedules a later wake of a session and acts on how far away the wake is:
7
+
8
+ - At most the step-down (57 minutes for send_later and Routines, 3480 seconds
9
+ for ScheduleWakeup): the call passes untouched.
10
+ - Over the step-down and at most 60 minutes: the call runs with the delay
11
+ rewritten to the step-down through ``updatedInput``.
12
+ - Over 60 minutes: the call is denied with a reason that names the step-down and
13
+ tells the session to stack wakes under it.
14
+
15
+ A cron schedule cannot be rewritten, so a cron that leaves more than the
16
+ step-down between wakes is denied. A Routine that starts a fresh session on each
17
+ firing has no cache to keep and passes. An ``update_trigger`` cron change passes,
18
+ because its input does not say whether the Routine wakes a session.
19
+
20
+ The step-down values come from fire-time probes: send_later rounds its fire time
21
+ up to the next minute and then waits up to one more minute for its poll, so it
22
+ lands up to two minutes after its requested delay. ScheduleWakeup rounds its
23
+ fire time up to the next minute and lands a few seconds after it, so it lands up
24
+ to about 64 seconds after its requested delay.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import math
31
+ import sys
32
+ from dataclasses import dataclass
33
+ from datetime import datetime, timedelta, timezone
34
+ from pathlib import Path
35
+
36
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
37
+ if hooks_root_directory not in sys.path:
38
+ sys.path.insert(0, hooks_root_directory)
39
+
40
+ from hooks_constants.hook_block_logger import log_hook_block
41
+ from hooks_constants.hook_specific_output_keys import (
42
+ HOOK_EVENT_NAME_KEY,
43
+ HOOK_SPECIFIC_OUTPUT_KEY,
44
+ PERMISSION_DECISION_KEY,
45
+ UPDATED_INPUT_KEY,
46
+ )
47
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
48
+ from hooks_constants.wakeup_cap_constants import (
49
+ ADDITIONAL_CONTEXT_KEY,
50
+ ALL_CRON_FIELD_RANGES,
51
+ ALLOW_DECISION,
52
+ ALLOW_EXIT_CODE,
53
+ AT_KEY,
54
+ BLOCK_EXIT_CODE,
55
+ CACHE_LIFETIME_MINUTES,
56
+ CACHE_LIFETIME_SECONDS,
57
+ CREATE_TRIGGER_TOOL_PATTERN,
58
+ CRON_CREATE_TOOL_NAME,
59
+ CRON_EXPRESSION_KEY,
60
+ CRON_FIELD_COUNT,
61
+ CRON_KEY,
62
+ CRON_TIMEZONE_PREFIX_PATTERN,
63
+ DELAY_MINUTES_KEY,
64
+ DELAY_SECONDS_KEY,
65
+ DENY_CRON_DETAIL,
66
+ DENY_DELAY_DETAIL,
67
+ DENY_REASON_TEMPLATE,
68
+ GATE_EVENT_NAME,
69
+ MINUTES_PER_HOUR,
70
+ NEW_SESSION_ON_FIRE_KEY,
71
+ PINNED_FIELD_COUNT,
72
+ RECURRING_KEY,
73
+ REWRITE_CONTEXT_TEMPLATE,
74
+ RFC3339_OUTPUT_FORMAT,
75
+ RUN_ONCE_AT_KEY,
76
+ SCHEDULE_WAKEUP_STEP_DOWN_SECONDS,
77
+ SCHEDULE_WAKEUP_TOOL_NAME,
78
+ SECONDS_PER_MINUTE,
79
+ SEND_LATER_STEP_DOWN_MINUTES,
80
+ SEND_LATER_TOOL_PATTERN,
81
+ TOOL_INPUT_KEY,
82
+ TOOL_NAME_KEY,
83
+ UPDATE_TRIGGER_TOOL_PATTERN,
84
+ UTC_OFFSET_TEXT,
85
+ UTC_SUFFIX,
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Rewrite:
91
+ """A tool input field to replace with the step-down value."""
92
+
93
+ field: str
94
+ requested: object
95
+ allowed: object
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Denial:
100
+ """The part of the deny reason that names what the call asked for."""
101
+
102
+ detail: str
103
+
104
+
105
+ Decision = Rewrite | Denial | None
106
+
107
+
108
+ def _number_or_none(value: object) -> float | None:
109
+ if isinstance(value, bool) or not isinstance(value, int | float):
110
+ return None
111
+ return float(value)
112
+
113
+
114
+ def _parse_utc_moment(text: object) -> datetime | None:
115
+ if not isinstance(text, str):
116
+ return None
117
+ try:
118
+ moment = datetime.fromisoformat(text.strip().replace(UTC_SUFFIX, UTC_OFFSET_TEXT))
119
+ except ValueError:
120
+ return None
121
+ if moment.tzinfo is None:
122
+ return None
123
+ return moment
124
+
125
+
126
+ def _decide_minutes(
127
+ field: str, requested: object, minutes_away: float, allowed: object
128
+ ) -> Decision:
129
+ if minutes_away > CACHE_LIFETIME_MINUTES:
130
+ return Denial(DENY_DELAY_DETAIL.format(minutes=math.ceil(minutes_away)))
131
+ if minutes_away > SEND_LATER_STEP_DOWN_MINUTES:
132
+ return Rewrite(field, requested, allowed)
133
+ return None
134
+
135
+
136
+ def _decide_delay_minutes(all_tool_input_fields: dict[str, object]) -> Decision:
137
+ requested = all_tool_input_fields.get(DELAY_MINUTES_KEY)
138
+ requested_minutes = _number_or_none(requested)
139
+ if requested_minutes is None:
140
+ return None
141
+ return _decide_minutes(
142
+ DELAY_MINUTES_KEY, requested, requested_minutes, SEND_LATER_STEP_DOWN_MINUTES
143
+ )
144
+
145
+
146
+ def _decide_fire_time(
147
+ field: str, all_tool_input_fields: dict[str, object], now: datetime
148
+ ) -> Decision:
149
+ requested = all_tool_input_fields.get(field)
150
+ fire_time = _parse_utc_moment(requested)
151
+ if fire_time is None:
152
+ return None
153
+ minutes_away = (fire_time - now).total_seconds() / SECONDS_PER_MINUTE
154
+ latest_allowed = (now + timedelta(minutes=SEND_LATER_STEP_DOWN_MINUTES)).replace(
155
+ second=0, microsecond=0
156
+ )
157
+ allowed_text = latest_allowed.astimezone(timezone.utc).strftime(RFC3339_OUTPUT_FORMAT)
158
+ return _decide_minutes(field, requested, minutes_away, allowed_text)
159
+
160
+
161
+ def _decide_delay_seconds(all_tool_input_fields: dict[str, object]) -> Decision:
162
+ requested = all_tool_input_fields.get(DELAY_SECONDS_KEY)
163
+ requested_seconds = _number_or_none(requested)
164
+ if requested_seconds is None:
165
+ return None
166
+ if requested_seconds > CACHE_LIFETIME_SECONDS:
167
+ return Denial(
168
+ DENY_DELAY_DETAIL.format(minutes=math.ceil(requested_seconds / SECONDS_PER_MINUTE))
169
+ )
170
+ if requested_seconds > SCHEDULE_WAKEUP_STEP_DOWN_SECONDS:
171
+ return Rewrite(DELAY_SECONDS_KEY, requested, SCHEDULE_WAKEUP_STEP_DOWN_SECONDS)
172
+ return None
173
+
174
+
175
+ def _base_bounds(base: str, has_step: bool, lowest: int, highest: int) -> tuple[int, int] | None:
176
+ if base == "*":
177
+ return lowest, highest
178
+ first_text, separator, last_text = base.partition("-")
179
+ if separator and first_text.isdigit() and last_text.isdigit():
180
+ return int(first_text), int(last_text)
181
+ if separator or not base.isdigit():
182
+ return None
183
+ return int(base), highest if has_step else int(base)
184
+
185
+
186
+ def _part_values(cron_part: str, lowest: int, highest: int) -> range | None:
187
+ base, _, step_text = cron_part.partition("/")
188
+ if step_text and not step_text.isdigit():
189
+ return None
190
+ step = int(step_text) if step_text else 1
191
+ bounds = _base_bounds(base, bool(step_text), lowest, highest)
192
+ if bounds is None or step < 1 or not lowest <= bounds[0] <= bounds[1] <= highest:
193
+ return None
194
+ return range(bounds[0], bounds[1] + 1, step)
195
+
196
+
197
+ def _field_values(cron_field: str, lowest: int, highest: int) -> set[int] | None:
198
+ all_values: set[int] = set()
199
+ for each_part in cron_field.split(","):
200
+ part_values = _part_values(each_part, lowest, highest)
201
+ if part_values is None:
202
+ return None
203
+ all_values.update(part_values)
204
+ return all_values
205
+
206
+
207
+ def _covers_every_value(cron_field: str, lowest: int, highest: int, wrap: int) -> bool:
208
+ all_values = _field_values(cron_field, lowest, highest)
209
+ if all_values is None:
210
+ return False
211
+ return {each_value % wrap for each_value in all_values} == set(range(lowest, wrap))
212
+
213
+
214
+ def _cron_fields(cron_expression: str) -> list[str] | None:
215
+ all_fields = CRON_TIMEZONE_PREFIX_PATTERN.sub("", cron_expression.strip()).split()
216
+ if len(all_fields) != CRON_FIELD_COUNT:
217
+ return None
218
+ return all_fields
219
+
220
+
221
+ def longest_cron_gap_minutes(cron_expression: str) -> int | None:
222
+ """Return the longest wait between two firings of a cron schedule, in minutes.
223
+
224
+ A schedule that limits its hour, day, month or weekday leaves an hour or more
225
+ between some firings, so it reports the cache lifetime as a floor.
226
+
227
+ >>> longest_cron_gap_minutes("*/20 * * * *")
228
+ 20
229
+ >>> longest_cron_gap_minutes("5 9 * * *")
230
+ 60
231
+
232
+ Args:
233
+ cron_expression: A 5-field cron expression, with an optional CRON_TZ prefix.
234
+
235
+ Returns:
236
+ The longest gap in minutes, or None when the expression cannot be read.
237
+ """
238
+ all_fields = _cron_fields(cron_expression)
239
+ if all_fields is None:
240
+ return None
241
+ all_minutes = _field_values(all_fields[0], 0, MINUTES_PER_HOUR - 1)
242
+ if not all_minutes:
243
+ return None
244
+ if not _fires_every_hour(all_fields[1:]):
245
+ return CACHE_LIFETIME_MINUTES
246
+ return _longest_minute_gap(all_minutes)
247
+
248
+
249
+ def _fires_every_hour(all_hour_and_day_fields: list[str]) -> bool:
250
+ return all(
251
+ _covers_every_value(each_field, lowest, highest, wrap)
252
+ for each_field, (lowest, highest, wrap) in zip(
253
+ all_hour_and_day_fields, ALL_CRON_FIELD_RANGES, strict=True
254
+ )
255
+ )
256
+
257
+
258
+ def _longest_minute_gap(all_minutes: set[int]) -> int:
259
+ ordered_minutes = sorted(all_minutes)
260
+ all_gaps = [
261
+ later - earlier
262
+ for earlier, later in zip(ordered_minutes, ordered_minutes[1:], strict=False)
263
+ ]
264
+ all_gaps.append(ordered_minutes[0] + MINUTES_PER_HOUR - ordered_minutes[-1])
265
+ return max(all_gaps)
266
+
267
+
268
+ def _decide_cron_gap(cron_expression: object) -> Decision:
269
+ if not isinstance(cron_expression, str):
270
+ return None
271
+ longest_gap = longest_cron_gap_minutes(cron_expression)
272
+ if longest_gap is None or longest_gap <= SEND_LATER_STEP_DOWN_MINUTES:
273
+ return None
274
+ return Denial(DENY_CRON_DETAIL.format(cron=cron_expression, minutes=longest_gap))
275
+
276
+
277
+ def _pinned_local_fire_time(cron_expression: str, now: datetime) -> datetime | None:
278
+ if CRON_TIMEZONE_PREFIX_PATTERN.match(cron_expression.strip()):
279
+ return None
280
+ all_fields = _cron_fields(cron_expression)
281
+ if all_fields is None:
282
+ return None
283
+ all_pinned_fields = all_fields[:PINNED_FIELD_COUNT]
284
+ if not all(each_field.isdigit() for each_field in all_pinned_fields):
285
+ return None
286
+ minute, hour, day, month = (int(each_field) for each_field in all_pinned_fields)
287
+ local_now = now.astimezone()
288
+ try:
289
+ fire_time = local_now.replace(
290
+ month=month, day=day, hour=hour, minute=minute, second=0, microsecond=0
291
+ )
292
+ if fire_time < local_now.replace(second=0, microsecond=0):
293
+ fire_time = fire_time.replace(year=fire_time.year + 1)
294
+ except ValueError:
295
+ return None
296
+ return fire_time
297
+
298
+
299
+ def _decide_pinned_one_shot(fire_time: datetime, now: datetime) -> Decision:
300
+ minutes_away = (fire_time - now).total_seconds() / SECONDS_PER_MINUTE
301
+ if minutes_away > SEND_LATER_STEP_DOWN_MINUTES:
302
+ return Denial(DENY_DELAY_DETAIL.format(minutes=math.ceil(minutes_away)))
303
+ return None
304
+
305
+
306
+ def _decide_cron_create(all_tool_input_fields: dict[str, object], now: datetime) -> Decision:
307
+ cron_expression = all_tool_input_fields.get(CRON_KEY)
308
+ if not isinstance(cron_expression, str):
309
+ return None
310
+ is_one_shot = all_tool_input_fields.get(RECURRING_KEY) is False
311
+ fire_time = _pinned_local_fire_time(cron_expression, now) if is_one_shot else None
312
+ if fire_time is not None:
313
+ return _decide_pinned_one_shot(fire_time, now)
314
+ return _decide_cron_gap(cron_expression)
315
+
316
+
317
+ def _decide_create_trigger(all_tool_input_fields: dict[str, object], now: datetime) -> Decision:
318
+ if all_tool_input_fields.get(NEW_SESSION_ON_FIRE_KEY) is True:
319
+ return None
320
+ if RUN_ONCE_AT_KEY in all_tool_input_fields:
321
+ return _decide_fire_time(RUN_ONCE_AT_KEY, all_tool_input_fields, now)
322
+ return _decide_cron_gap(all_tool_input_fields.get(CRON_EXPRESSION_KEY))
323
+
324
+
325
+ def decide(tool_name: str, all_tool_input_fields: dict[str, object], now: datetime) -> Decision:
326
+ """Return how the gate treats one tool call that may schedule a wake.
327
+
328
+ Args:
329
+ tool_name: The name of the tool being called.
330
+ all_tool_input_fields: The tool call's input.
331
+ now: The current time, timezone aware.
332
+
333
+ Returns:
334
+ A Rewrite, a Denial, or None when the call passes untouched.
335
+ """
336
+ if tool_name == SCHEDULE_WAKEUP_TOOL_NAME:
337
+ return _decide_delay_seconds(all_tool_input_fields)
338
+ if tool_name == CRON_CREATE_TOOL_NAME:
339
+ return _decide_cron_create(all_tool_input_fields, now)
340
+ if SEND_LATER_TOOL_PATTERN.match(tool_name):
341
+ if DELAY_MINUTES_KEY in all_tool_input_fields:
342
+ return _decide_delay_minutes(all_tool_input_fields)
343
+ return _decide_fire_time(AT_KEY, all_tool_input_fields, now)
344
+ if CREATE_TRIGGER_TOOL_PATTERN.match(tool_name):
345
+ return _decide_create_trigger(all_tool_input_fields, now)
346
+ if UPDATE_TRIGGER_TOOL_PATTERN.match(tool_name):
347
+ return _decide_fire_time(RUN_ONCE_AT_KEY, all_tool_input_fields, now)
348
+ return None
349
+
350
+
351
+ def _write_rewrite(all_tool_input_fields: dict[str, object], rewrite: Rewrite) -> None:
352
+ payload = {
353
+ HOOK_SPECIFIC_OUTPUT_KEY: {
354
+ HOOK_EVENT_NAME_KEY: GATE_EVENT_NAME,
355
+ PERMISSION_DECISION_KEY: ALLOW_DECISION,
356
+ UPDATED_INPUT_KEY: {**all_tool_input_fields, rewrite.field: rewrite.allowed},
357
+ ADDITIONAL_CONTEXT_KEY: REWRITE_CONTEXT_TEMPLATE.format(
358
+ field=rewrite.field,
359
+ requested=rewrite.requested,
360
+ allowed=rewrite.allowed,
361
+ cap=f"{CACHE_LIFETIME_MINUTES} minutes",
362
+ ),
363
+ }
364
+ }
365
+ sys.stdout.write(json.dumps(payload) + "\n")
366
+
367
+
368
+ def main() -> int:
369
+ hook_input = read_hook_input_dictionary_from_stdin()
370
+ if hook_input is None:
371
+ return ALLOW_EXIT_CODE
372
+ tool_name = hook_input.get(TOOL_NAME_KEY)
373
+ tool_input = hook_input.get(TOOL_INPUT_KEY)
374
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
375
+ return ALLOW_EXIT_CODE
376
+ decision = decide(tool_name, tool_input, datetime.now(timezone.utc))
377
+ if isinstance(decision, Rewrite):
378
+ _write_rewrite(tool_input, decision)
379
+ return ALLOW_EXIT_CODE
380
+ if decision is None:
381
+ return ALLOW_EXIT_CODE
382
+ block_reason = DENY_REASON_TEMPLATE.format(
383
+ tool_name=tool_name,
384
+ detail=decision.detail,
385
+ send_later_cap=SEND_LATER_STEP_DOWN_MINUTES,
386
+ schedule_wakeup_cap=SCHEDULE_WAKEUP_STEP_DOWN_SECONDS,
387
+ )
388
+ log_hook_block(
389
+ Path(__file__).name,
390
+ GATE_EVENT_NAME,
391
+ block_reason,
392
+ tool_name=tool_name,
393
+ offending_input_preview=json.dumps(tool_input),
394
+ )
395
+ sys.stderr.write(block_reason)
396
+ return BLOCK_EXIT_CODE
397
+
398
+
399
+ if __name__ == "__main__":
400
+ sys.exit(main())
@@ -15,6 +15,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
15
15
  - `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
16
16
  - `blocking/session_title_turn_gate.py` holds the end of a turn once, in a session that has a `__set_session_title` tool, when the shown title does not fit the session's state: no title yet, a red flag after a prompt that asks for work, a check mark while the task list has open items, or an hourglass once every item is completed or deleted. A turn that set a title passes. A turn that called no tool other than the title tool passes, and so does a held turn.
17
17
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
18
+ - `blocking/wakeup_cap_gate.py` keeps a self-wakeup inside the 1-hour prompt cache. A wake over the step-down (57 minutes for `send_later` and Routines, 3480 seconds for `ScheduleWakeup`) and at most 60 minutes away runs with its delay rewritten to the step-down. A wake over 60 minutes away, or a cron with more than 57 minutes between wakes, is denied with a reason that says to stack wakes under the cap. A Routine that starts a fresh session on each firing passes.
18
19
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
19
20
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
20
21
  - `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
39
40
  - `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
40
41
  - `blocking/session_title_turn_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
41
42
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
43
+ - `blocking/wakeup_cap_gate.py` runs on `PreToolUse`, matcher `ScheduleWakeup|CronCreate|mcp__.*__(send_later|create_trigger|update_trigger)`, timeout `10` seconds in `hooks.json`.
42
44
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
43
45
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
44
46
  - `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write`, timeout `10` seconds in `hooks.json`.
@@ -68,6 +70,7 @@ Preconditions:
68
70
  - **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
69
71
  - **Stale title.** Input is a transcript that lists the title tool, an earlier turn that set the red flag, and a work prompt whose turn ran a tool and set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_turn_gate.py -q`. The adjacent test observes a `block` decision that names the shown title, the tool, and the four status emoji. A carried hourglass with an item in progress passes, and so does a turn whose title call came back without an error.
70
72
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
73
+ - **Wakeup cap.** Input is a `send_later` call with `delay_minutes` of 60, then 90, then 45. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_wakeup_cap_gate.py -q`. The adjacent test observes 60 rewritten to 57 through `updatedInput`, 90 denied with the stacking instruction, and 45 passing untouched.
71
74
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
72
75
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
73
76
  - **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a commit of data files under a `docs` folder, a tests-only commit, and a repository with no verify skill pass. A `.py` file under `docs` and a `.json` file outside `docs` still deny.
@@ -84,6 +87,7 @@ Preconditions:
84
87
  - `blocking/turn_review_gate.py` starts `claude -p`, which inherits the parent session id unless the gate strips it. A review that kept `CLAUDE_CODE_SESSION_ID` would write into the parent's transcript.
85
88
  - Each review run calls a model and costs about $0.03. Leave `CLAUDE_TURN_REVIEW_REPOSITORIES` unset and the gate makes no snapshot and no call.
86
89
  - `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
90
+ - `blocking/wakeup_cap_gate.py` lets an `update_trigger` cron change pass, because that input does not say whether the Routine wakes a session. Its step-down values come from fire-time probes: `send_later` rounds its fire time up to the next minute and polls once a minute, so a 1-minute request landed 123 and 158 seconds later. `ScheduleWakeup` rounds its fire time up to the next minute, so requests of 60 and 90 seconds landed 70 and 121 seconds later.
87
91
  - `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
88
92
  - The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
89
93
  - The final reply gate blocks the stop and returns its reason to the model, which rewrites the reply. Claude Code ends the turn anyway after eight blocks in a row. The cited number checks in both gates skip when the transcript cannot be read.
@@ -5,7 +5,7 @@ This family reminds the agent to gather context before a spawn, refuses a brief
5
5
  ## Checks
6
6
 
7
7
  - `routing/spawn_readiness_hook.py` adds context to a spawn that comes before a read step, or before an answered question or a settled-scope line. The spawn still runs.
8
- - `routing/spawn_oversight_hook.py` denies a spawn whose brief has no `Outcome:` line or no `On a blocker:` clause, each with text after it. A spawn that passes gets the orchestrator oversight directive: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. `routing/spawn_brief_text.py` reads the brief from each spawn tool for this hook and the readiness hook.
8
+ - `routing/spawn_oversight_hook.py` denies a spawn whose brief has no `Outcome:` line or no `On a blocker:` clause, each with text after it. A spawn that passes gets the orchestrator oversight directive: run each piece through the verified-work flow, with a fresh verifier agent that checks the research, the plan, and the fix, reproduces the issue before it confirms the fix, and leaves the worker's conclusion out of its brief; ask the user before new scope; report as done only what a verifier passed; and watch the agent while it runs. `routing/spawn_brief_text.py` reads the brief from each spawn tool for this hook and the readiness hook.
9
9
  - `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
10
10
  - `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
11
11
  - `blocking/haiku_spawn_slim_gate.py` denies an Agent, Task, or thread spawn whose `model` names Haiku. The deny reason gives the headless `claude -p` command that loads the slim profile through the account broker.
package/hooks/hooks.json CHANGED
@@ -224,6 +224,16 @@
224
224
  }
225
225
  ]
226
226
  },
227
+ {
228
+ "matcher": "ScheduleWakeup|CronCreate|mcp__.*__(send_later|create_trigger|update_trigger)",
229
+ "hooks": [
230
+ {
231
+ "type": "command",
232
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/wakeup_cap_gate.py",
233
+ "timeout": 10
234
+ }
235
+ ]
236
+ },
227
237
  {
228
238
  "matcher": "Artifact",
229
239
  "hooks": [
@@ -36,11 +36,17 @@ REFUSAL_SUFFIX = (
36
36
  REASON_SEPARATOR = " "
37
37
 
38
38
  SPAWN_OVERSIGHT_DIRECTIVE = (
39
- "You own the work this spawn starts. Load the orchestrator skill and follow "
40
- "its oversee delegated work reference. While the agent runs, read its progress "
41
- "and wake it with one next step when it goes quiet. Before its output "
42
- "reaches the user, check it against the user's own words and standards, and "
43
- "send the agent a correction when it misses. Give each piece of a "
44
- "multi-piece task its own reviewer or helper. Tell the user you are "
45
- "checking the work."
39
+ "You own the work this spawn starts. Run each piece through the verified-work "
40
+ "flow: research, then a separate verifier checks the research; plan, then a "
41
+ "separate verifier checks that the plan fixes the researched issue; implement, "
42
+ "then a separate verifier reproduces the issue on the old state before it "
43
+ "confirms the fix on the new state. Spawn a fresh verifier agent for each "
44
+ "check, never the agent that did the work, and brief it with the issue and "
45
+ "the sources and leave the worker's conclusion out. A verifier's check ends "
46
+ "the chain and gets no verifier of its own. Ask the user once before "
47
+ "you build new scope the user's words do not name, and keep in-scope work "
48
+ "moving while the question waits. Report as done only what a verifier "
49
+ "passed, with its evidence. While the agent runs, read its progress and wake "
50
+ "it with one next step when it goes quiet. Load the orchestrator skill and "
51
+ "follow its oversee delegated work reference."
46
52
  )
@@ -0,0 +1,56 @@
1
+ """Constants for wakeup_cap_gate.py."""
2
+
3
+ import re
4
+
5
+ TOOL_NAME_KEY = "tool_name"
6
+ TOOL_INPUT_KEY = "tool_input"
7
+ GATE_EVENT_NAME = "PreToolUse"
8
+ ALLOW_EXIT_CODE = 0
9
+ BLOCK_EXIT_CODE = 2
10
+ ALLOW_DECISION = "allow"
11
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
12
+
13
+ SCHEDULE_WAKEUP_TOOL_NAME = "ScheduleWakeup"
14
+ CRON_CREATE_TOOL_NAME = "CronCreate"
15
+ SEND_LATER_TOOL_PATTERN = re.compile(r"^mcp__.+__send_later$")
16
+ CREATE_TRIGGER_TOOL_PATTERN = re.compile(r"^mcp__.+__create_trigger$")
17
+ UPDATE_TRIGGER_TOOL_PATTERN = re.compile(r"^mcp__.+__update_trigger$")
18
+
19
+ DELAY_SECONDS_KEY = "delaySeconds"
20
+ DELAY_MINUTES_KEY = "delay_minutes"
21
+ AT_KEY = "at"
22
+ RUN_ONCE_AT_KEY = "run_once_at"
23
+ CRON_EXPRESSION_KEY = "cron_expression"
24
+ CRON_KEY = "cron"
25
+ RECURRING_KEY = "recurring"
26
+ NEW_SESSION_ON_FIRE_KEY = "create_new_session_on_fire"
27
+
28
+ SECONDS_PER_MINUTE = 60
29
+ MINUTES_PER_HOUR = 60
30
+ CACHE_LIFETIME_MINUTES = 60
31
+ CACHE_LIFETIME_SECONDS = CACHE_LIFETIME_MINUTES * SECONDS_PER_MINUTE
32
+ SEND_LATER_STEP_DOWN_MINUTES = 57
33
+ SCHEDULE_WAKEUP_STEP_DOWN_SECONDS = 3480
34
+
35
+ CRON_TIMEZONE_PREFIX_PATTERN = re.compile(r"^CRON_TZ=\S+\s+")
36
+ CRON_FIELD_COUNT = 5
37
+ PINNED_FIELD_COUNT = 4
38
+ ALL_CRON_FIELD_RANGES = ((0, 23, 24), (1, 31, 32), (1, 12, 13), (0, 7, 7))
39
+ UTC_SUFFIX = "Z"
40
+ UTC_OFFSET_TEXT = "+00:00"
41
+ RFC3339_OUTPUT_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
42
+
43
+ REWRITE_CONTEXT_TEMPLATE = (
44
+ "wakeup_cap_gate rewrote {field} from {requested} to {allowed}. A wake {cap} or later"
45
+ " lands after the 1-hour prompt cache expires, so this wake now fires sooner."
46
+ )
47
+ DENY_REASON_TEMPLATE = (
48
+ "wakeup_cap_gate refused {tool_name}: {detail} A wake 60 minutes or more after the last"
49
+ " turn lands after the 1-hour prompt cache expires and resumes cold. Arm a wake of at"
50
+ " most {send_later_cap} minutes (send_later delay_minutes={send_later_cap}, ScheduleWakeup"
51
+ " delaySeconds={schedule_wakeup_cap}). For a longer wait, stack wakes under that cap:"
52
+ " put the remaining wait in the wake prompt and re-arm on each wake until it is covered,"
53
+ " as the usage-pause skill's stages_seconds chain does."
54
+ )
55
+ DENY_DELAY_DETAIL = "the wake is {minutes} minutes away."
56
+ DENY_CRON_DETAIL = "the cron '{cron}' leaves {minutes} minutes or more between wakes."
@@ -232,3 +232,43 @@ def test_brief_gaps_should_name_each_missing_clause() -> None:
232
232
  def test_a_workflow_script_path_that_cannot_be_opened_gets_the_directive() -> None:
233
233
  payload = {"tool_name": "Workflow", "tool_input": {"scriptPath": "bad\u0000path.js"}}
234
234
  assert json.loads(_run_script(json.dumps(payload))) == _directive_output()
235
+
236
+
237
+ def _emitted_directive() -> str:
238
+ stdout = _run_script(json.dumps(_spawn_payload("Agent", GOOD_BRIEF)))
239
+ return json.loads(stdout)["hookSpecificOutput"]["additionalContext"]
240
+
241
+
242
+ def test_the_emitted_directive_runs_research_plan_and_fix_in_order() -> None:
243
+ directive = _emitted_directive()
244
+ research_position = directive.index("research, then a separate verifier checks the research")
245
+ plan_position = directive.index("plan, then a separate verifier checks that the plan fixes the researched issue")
246
+ fix_position = directive.index("implement, then a separate verifier reproduces the issue")
247
+ assert research_position < plan_position < fix_position
248
+
249
+
250
+ def test_the_emitted_directive_reproduces_the_issue_before_it_confirms_the_fix() -> None:
251
+ directive = _emitted_directive()
252
+ assert directive.index("reproduces the issue on the old state") < directive.index(
253
+ "confirms the fix on the new state"
254
+ )
255
+
256
+
257
+ def test_the_emitted_directive_keeps_the_verifier_apart_from_the_worker() -> None:
258
+ directive = _emitted_directive()
259
+ assert "Spawn a fresh verifier agent for each check, never the agent that did the work" in directive
260
+ assert "leave the worker's conclusion out" in directive
261
+
262
+
263
+ def test_the_emitted_directive_asks_before_new_scope_and_keeps_in_scope_work_moving() -> None:
264
+ directive = _emitted_directive()
265
+ assert "Ask the user once before you build new scope" in directive
266
+ assert "keep in-scope work moving while the question waits" in directive
267
+
268
+
269
+ def test_the_emitted_directive_reports_only_verified_results() -> None:
270
+ assert "Report as done only what a verifier passed, with its evidence" in _emitted_directive()
271
+
272
+
273
+ def test_the_emitted_directive_ends_the_chain_at_the_verifier() -> None:
274
+ assert "A verifier's check ends the chain and gets no verifier of its own" in _emitted_directive()
@@ -31,6 +31,7 @@ UNMEASURED_REASON_BY_SCRIPT = {
31
31
  "project registry names by absolute path",
32
32
  "session/plugin_change_warning.py": "emits only after a plugin changes on disk mid-session, "
33
33
  "once per plugin, naming plugin ids",
34
+ "blocking/wakeup_cap_gate.py": "emits one fixed-template line only when it rewrites a wake delay",
34
35
  }
35
36
 
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.103.1",
3
+ "version": "8.105.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/index.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # Rules
2
- Each line gives a rule in brief. When its trigger matches your task, open the guide and follow it in full. Correction lens, question presentation, and the agent board load as their own files.
2
+ Each line gives a rule in brief. When its trigger matches your task, open the guide and follow it in full.
3
3
 
4
4
  **Writing**
5
5
  - **Plain language** ([guide](../docs/rule-guides/asd-ste100-language.md)). Writing chat, narration, or repository prose. Short complete sentences on one topic, active voice, one action per step, each fact said once.
6
- - **No contrast framing** ([guide](../docs/rule-guides/no-contrast-framing.md)). Any sentence a person reads. State the chosen point and leave out the discarded reading. Remove the six forms: `trailing-comma-not`, `corrective-it-is-not`, `substitution-rather-than`, `additive-not-just`, `comparative-ranking`, `substitution-as-opposed-to`.
6
+ - **No contrast framing** ([guide](../docs/rule-guides/no-contrast-framing.md)). Any sentence a person reads. State the chosen point and leave out the discarded reading.
7
7
 
8
8
  **Evidence**
9
9
  - **Research mode** ([guide](../docs/rule-guides/research-mode.md)). Any claim, recommendation, or advice. Cite a source for each claim, and run the tool that settles a fact before you state it.
10
10
  - **Verify before asking** ([guide](../docs/rule-guides/verify-before-asking.md)). Before a clarifying question. Look in files, config, environment, and tools first; ask only for a judgment or a preference.
11
+ - **Verified work** ([guide](../docs/rule-guides/verified-work.md)). Any task. Research, plan, and build; a fresh `verifier` agent checks each step and reproduces the issue before it confirms the fix. Report only verified results. Ask before new scope.
11
12
  - **Verify runtime state** ([guide](../docs/rule-guides/verify-runtime-state.md)). Before you say a component works, is healthy, or is not the cause. Get a live signal this session.
12
13
 
13
14
  **Before building**
@@ -21,10 +22,8 @@ Each line gives a rule in brief. When its trigger matches your task, open the gu
21
22
  - **Filesystem search** ([guide](../docs/rule-guides/filesystem-search.md)). Finding files by name, path, size, or date. Scope the search to a project, worktree, or filter, never a root.
22
23
  - **Clean up temporary files** ([guide](../docs/rule-guides/cleanup-temp-files.md)). Creating scratch files or debug dumps. Track them and remove them when the task ends.
23
24
 
24
- **Shipping**
25
+ **Shipping and memory**
25
26
  - **Pull request lifecycle**. Before a commit, push, pull request action, or merge. Invoke the `pr-lifecycle` skill; a hook denies those commands until it loads.
26
27
  - **Proof before a pull request** ([guide](../docs/rule-guides/proof-before-pull-request.md)). Before you open a pull request. Run the change with and without it, and quote both under "Proof in practice".
27
28
  - **Session title** ([guide](../docs/rule-guides/session-title.md)). The session has a tool whose name ends in `__set_session_title`. Set `<emoji> <name>` when the status changes.
28
-
29
- **Memory**
30
29
  - **Durable facts only** ([guide](../docs/rule-guides/memory-stores-durable-facts.md)). Writing auto-memory or a `MEMORY.md` entry. Keep only a fact that helps a fresh session weeks later.
@@ -146,6 +146,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
146
146
  hooks/blocking/session_title_turn_gate.py -> exempt
147
147
  hooks/blocking/resolve_thread_reply_gate.py -> exempt
148
148
  hooks/blocking/turn_review_gate.py -> exempt
149
+ hooks/blocking/wakeup_cap_gate.py -> exempt
149
150
  hooks/blocking/some_new_blocker.py -> flagged
150
151
 
151
152
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -213,6 +214,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
213
214
  the agent fixes or answers each finding; the gate itself only decides when
214
215
  a turn may end.
215
216
 
217
+ The wakeup cap gate shortens or denies a self-wakeup scheduled 60 minutes
218
+ or more away, so the wake lands inside the prompt cache. It decides when a
219
+ session wakes and no code or safety policy.
220
+
216
221
  Args:
217
222
  registered_string: One command, path, script, or entrypoint string.
218
223
  """
@@ -85,6 +85,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
85
85
  "blocking/session_title_turn_gate.py",
86
86
  "blocking/resolve_thread_reply_gate.py",
87
87
  "blocking/turn_review_gate.py",
88
+ "blocking/wakeup_cap_gate.py",
88
89
  )
89
90
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
90
91
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -0,0 +1,36 @@
1
+ """The always-loaded rules index carries the verified-work flow, and its guide spells out every step."""
2
+
3
+ from pathlib import Path
4
+
5
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
6
+ INDEX_PATH = PACKAGE_ROOT / "rules" / "index.md"
7
+ GUIDE_PATH = PACKAGE_ROOT / "docs" / "rule-guides" / "verified-work.md"
8
+
9
+
10
+ def test_the_index_points_every_session_at_the_verified_work_guide() -> None:
11
+ index_text = INDEX_PATH.read_text(encoding="utf-8")
12
+ assert "- **Verified work** ([guide](../docs/rule-guides/verified-work.md))." in index_text
13
+ assert "reproduces the issue before it confirms the fix" in index_text
14
+ assert "Ask before new scope." in index_text
15
+
16
+
17
+ def test_the_guide_runs_six_steps_in_order() -> None:
18
+ guide_text = GUIDE_PATH.read_text(encoding="utf-8")
19
+ all_step_markers = (
20
+ "1. **Research.**",
21
+ "2. **Verify the research.**",
22
+ "3. **Plan.**",
23
+ "4. **Verify the plan.**",
24
+ "5. **Implement.**",
25
+ "6. **Verify the fix.**",
26
+ )
27
+ all_positions = [guide_text.index(each_marker) for each_marker in all_step_markers]
28
+ assert all_positions == sorted(all_positions)
29
+
30
+
31
+ def test_the_guide_keeps_the_verifier_apart_and_asks_before_new_scope() -> None:
32
+ guide_text = GUIDE_PATH.read_text(encoding="utf-8")
33
+ assert "The verifier is never the agent that did the work." in guide_text
34
+ assert "A fix whose issue did not reproduce does not pass." in guide_text
35
+ assert "New scope goes to the user as one question before any of it is built" in guide_text
36
+ assert "In-scope work runs with no ask." in guide_text