claude-dev-env 1.84.0 → 1.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/code_rules_docstrings.py +17 -13
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_naming_collection.py +5 -3
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/re-stage-before-commit.md +31 -0
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +46 -3
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/pr-converge/SKILL.md +27 -1
- package/skills/pr-converge/reference/state-schema.md +12 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook — start a task-list maintenance loop for the session.
|
|
3
|
+
|
|
4
|
+
At session start this hook emits an ``additionalContext`` directive asking Claude
|
|
5
|
+
to keep the session's task list current on a 10-minute cadence, starting the
|
|
6
|
+
``/loop`` skill when one is not already running. The hook writes nothing and runs
|
|
7
|
+
no tools itself. Claude reads the directive and invokes the ``/loop`` skill.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _hooks_dir not in sys.path:
|
|
18
|
+
sys.path.insert(0, _hooks_dir)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.task_list_loop_starter_constants import ( # noqa: E402
|
|
21
|
+
TASK_LIST_LOOP_DIRECTIVE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_session_directive() -> str:
|
|
26
|
+
"""Return the task-list loop directive emitted at session start."""
|
|
27
|
+
return TASK_LIST_LOOP_DIRECTIVE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
"""Emit the task-list loop directive as SessionStart additionalContext."""
|
|
32
|
+
print(json.dumps({"additionalContext": build_session_directive()}))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Behavior tests for the session_edit_tracker_cleanup hook.
|
|
2
|
+
|
|
3
|
+
The cleanup deletes only the running session's own session-edit file, and only
|
|
4
|
+
on a fresh startup (SessionStart with a ``startup`` source) or a session end. A
|
|
5
|
+
SessionStart that continues the same conversation — a compact or a resume —
|
|
6
|
+
keeps the in-flight tracker. A peer session's file is always left alone,
|
|
7
|
+
regardless of its age. These tests redirect ``tempfile.gettempdir`` to a
|
|
8
|
+
temporary directory, seed it with the current session's file and peer-session
|
|
9
|
+
files, run the hook's ``main`` through a lifecycle payload, and confirm which
|
|
10
|
+
files survive.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pathlib
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from unittest import mock
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
29
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
30
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
31
|
+
if each_path not in sys.path:
|
|
32
|
+
sys.path.insert(0, each_path)
|
|
33
|
+
|
|
34
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
35
|
+
"session_edit_tracker_cleanup",
|
|
36
|
+
_HOOK_DIR / "session_edit_tracker_cleanup.py",
|
|
37
|
+
)
|
|
38
|
+
assert hook_spec is not None
|
|
39
|
+
assert hook_spec.loader is not None
|
|
40
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
41
|
+
hook_spec.loader.exec_module(hook_module)
|
|
42
|
+
|
|
43
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
44
|
+
ALL_EDITED_FILE_PATHS_KEY,
|
|
45
|
+
SESSION_EDIT_FILE_PREFIX,
|
|
46
|
+
SESSION_EDIT_FILE_SUFFIX,
|
|
47
|
+
STATE_FILE_DEFAULT_SESSION_ID,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
AGED_TRACKER_FILE_SECONDS = 1860
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_main_with_stdin(input_text: str) -> None:
|
|
54
|
+
with (
|
|
55
|
+
mock.patch("sys.stdin", io.StringIO(input_text)),
|
|
56
|
+
mock.patch("sys.stdout", new_callable=io.StringIO),
|
|
57
|
+
contextlib.suppress(SystemExit),
|
|
58
|
+
):
|
|
59
|
+
hook_module.main()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture()
|
|
63
|
+
def redirected_temp_directory(
|
|
64
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
65
|
+
) -> pathlib.Path:
|
|
66
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
67
|
+
return tmp_path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _seed_edit_file(temp_directory: pathlib.Path, session_id: str) -> pathlib.Path:
|
|
71
|
+
edit_file = temp_directory / f"{SESSION_EDIT_FILE_PREFIX}{session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
72
|
+
edit_file.write_text(json.dumps({ALL_EDITED_FILE_PATHS_KEY: []}), encoding="utf-8")
|
|
73
|
+
return edit_file
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _age_file(target_file: pathlib.Path, age_seconds: float) -> None:
|
|
77
|
+
old_time = time.time() - age_seconds
|
|
78
|
+
os.utime(target_file, (old_time, old_time))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _session_start_payload(session_id: str) -> str:
|
|
82
|
+
return json.dumps({"session_id": session_id, "source": "startup"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _session_continuation_payload(session_id: str, source: str) -> str:
|
|
86
|
+
return json.dumps({"session_id": session_id, "source": source})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _session_end_payload(session_id: str) -> str:
|
|
90
|
+
return json.dumps({"session_id": session_id, "hook_event_name": "SessionEnd"})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_main_keeps_other_session_stale_tracker(
|
|
94
|
+
redirected_temp_directory: pathlib.Path,
|
|
95
|
+
) -> None:
|
|
96
|
+
other_session_file = _seed_edit_file(redirected_temp_directory, "idle-live-session")
|
|
97
|
+
_age_file(other_session_file, AGED_TRACKER_FILE_SECONDS)
|
|
98
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
99
|
+
assert other_session_file.exists()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_keep_fresh_edit_file(redirected_temp_directory: pathlib.Path) -> None:
|
|
103
|
+
fresh_file = _seed_edit_file(redirected_temp_directory, "other-live-session")
|
|
104
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
105
|
+
assert fresh_file.exists()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_remove_current_session_edit_file(
|
|
109
|
+
redirected_temp_directory: pathlib.Path,
|
|
110
|
+
) -> None:
|
|
111
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
112
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
113
|
+
assert not current_file.exists()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_main_removes_current_session_stale_tracker(
|
|
117
|
+
redirected_temp_directory: pathlib.Path,
|
|
118
|
+
) -> None:
|
|
119
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
120
|
+
_age_file(current_file, AGED_TRACKER_FILE_SECONDS)
|
|
121
|
+
_run_main_with_stdin(_session_start_payload("currentsession"))
|
|
122
|
+
assert not current_file.exists()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize("continuation_source", ["compact", "resume"])
|
|
126
|
+
def test_should_keep_current_session_tracker_on_continuation(
|
|
127
|
+
redirected_temp_directory: pathlib.Path, continuation_source: str
|
|
128
|
+
) -> None:
|
|
129
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
130
|
+
_run_main_with_stdin(
|
|
131
|
+
_session_continuation_payload("currentsession", continuation_source)
|
|
132
|
+
)
|
|
133
|
+
assert current_file.exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_should_remove_current_session_tracker_on_session_end(
|
|
137
|
+
redirected_temp_directory: pathlib.Path,
|
|
138
|
+
) -> None:
|
|
139
|
+
current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
|
|
140
|
+
_run_main_with_stdin(_session_end_payload("currentsession"))
|
|
141
|
+
assert not current_file.exists()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_should_exit_zero_on_malformed_stdin(
|
|
145
|
+
redirected_temp_directory: pathlib.Path,
|
|
146
|
+
) -> None:
|
|
147
|
+
fresh_file = _seed_edit_file(redirected_temp_directory, "other-live-session")
|
|
148
|
+
_run_main_with_stdin("not valid json {{{")
|
|
149
|
+
assert fresh_file.exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_malformed_stdin_keeps_default_session_tracker(
|
|
153
|
+
redirected_temp_directory: pathlib.Path,
|
|
154
|
+
) -> None:
|
|
155
|
+
default_file = _seed_edit_file(redirected_temp_directory, STATE_FILE_DEFAULT_SESSION_ID)
|
|
156
|
+
_run_main_with_stdin("not valid json {{{")
|
|
157
|
+
assert default_file.exists()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tests for task_list_loop_starter — SessionStart hook that starts a task-list loop."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
10
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
11
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
12
|
+
if each_sys_path_entry not in sys.path:
|
|
13
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
14
|
+
|
|
15
|
+
import task_list_loop_starter as starter
|
|
16
|
+
|
|
17
|
+
from hooks_constants.task_list_loop_starter_constants import (
|
|
18
|
+
TASK_LIST_LOOP_DIRECTIVE,
|
|
19
|
+
TASK_LIST_MAINTENANCE_INSTRUCTION,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_main() -> str:
|
|
24
|
+
"""Return stdout produced by running the hook's main()."""
|
|
25
|
+
captured_stdout = StringIO()
|
|
26
|
+
with patch("sys.stdout", captured_stdout):
|
|
27
|
+
starter.main()
|
|
28
|
+
return captured_stdout.getvalue()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestSessionDirective:
|
|
32
|
+
def test_main_emits_additional_context(self) -> None:
|
|
33
|
+
emitted = json.loads(_run_main())
|
|
34
|
+
assert "additionalContext" in emitted
|
|
35
|
+
|
|
36
|
+
def test_directive_carries_the_one_line_instruction(self) -> None:
|
|
37
|
+
emitted = json.loads(_run_main())
|
|
38
|
+
assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["additionalContext"]
|
|
39
|
+
|
|
40
|
+
def test_directive_names_the_ten_minute_cadence(self) -> None:
|
|
41
|
+
emitted = json.loads(_run_main())
|
|
42
|
+
assert "10-minute" in emitted["additionalContext"]
|
|
43
|
+
|
|
44
|
+
def test_directive_names_the_loop_command(self) -> None:
|
|
45
|
+
emitted = json.loads(_run_main())
|
|
46
|
+
assert "/loop 10m" in emitted["additionalContext"]
|
|
47
|
+
|
|
48
|
+
def test_directive_is_idempotent_about_an_existing_loop(self) -> None:
|
|
49
|
+
emitted = json.loads(_run_main())
|
|
50
|
+
assert "not already running" in emitted["additionalContext"]
|
|
51
|
+
|
|
52
|
+
def test_build_session_directive_returns_the_shared_constant(self) -> None:
|
|
53
|
+
assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -36,6 +36,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
36
36
|
| `plain-illustrative-docstrings.md` | Public docstring narrative reads plainly and shows behavior with a diagram block (a `::` example or a doctest), painting a concrete scene a general developer follows on first read; a run-on backstop hook, a prose-wall backstop hook, and Category O9 audit enforce it |
|
|
37
37
|
| `plain-language.md` | Everyday words, short active sentences, lead with the answer |
|
|
38
38
|
| `prompt-workflow-context-controls.md` | Keep prompt-workflow instruction layers small and stable; load heavy skills on demand |
|
|
39
|
+
| `re-stage-before-commit.md` | Stage the files edited this session before `git commit`; the session edit stage gate denies a commit that leaves a tracked session edit unstaged, with `-a`, a pathspec, a preceding `git add`, and `# partial-commit` as escapes |
|
|
39
40
|
| `research-mode.md` | Three anti-hallucination constraints: say "I don't know", verify with citations, quote for factual grounding |
|
|
40
41
|
| `right-sized-engineering.md` | Simple over clever; functions over classes; concrete over abstract |
|
|
41
42
|
| `self-contained-docs.md` | Every document is fully self-contained; no references to the conversation that produced it |
|
|
@@ -30,7 +30,7 @@ Read the body and the docstring side by side:
|
|
|
30
30
|
- **Returns-clause cardinality.** A `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) matches the count of keys in that family in the returned dict literal. When the dict holds one key in the family (`sheen_mid`), the noun is singular (`the sheen stop`); a plural noun there claims two or more entries the dict does not hold. The `check_docstring_returns_plural_cardinality` gate blocks the single-key-with-plural-noun form of this drift at Write/Edit time.
|
|
31
31
|
- **Length-constant superlative vs exact gate.** A module docstring that describes an integer `*_LENGTH` constant with a superlative or range word (`the longest color string the swatch accepts`, `no longer than`) matches how the code consumes the constant. When the only consumer compares `len(...)` against the constant with `==`/`!=` — an exact-length gate where every other length is rejected, not accepted at a shorter length — the superlative prose claims a range of accepted lengths the code never allows. State the exact required length (`the exact #AARRGGBB length`), not a longest/range form. The `check_docstring_length_constant_superlative_vs_exact_gate` gate blocks this drift at Write/Edit time, scanning the constant module's package tree so it sees a consumer that lives in a sibling module; a constant genuinely used as a ceiling (`len(x) <= LIMIT`) is left alone.
|
|
32
32
|
- **Args single-line scope vs span body.** An `Args:` entry that scopes a finding to one named line (`a finding blocks only when its block-anchor line is among the changed lines`) matches the line breadth the body scopes by. When the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper that blocks when any line of the span is among the changed lines, the single-line Args wording understates the scope: an edit touching a non-anchor line of the span still blocks. State the Args entry on the same span breadth the body uses (`a finding blocks when any line of its block span is among the changed lines`). The `check_docstring_args_single_line_scope_vs_span` gate blocks the single-line-Args-over-span-body form of this drift at Write/Edit time.
|
|
33
|
-
- **Cardinal-count enumerations.** A docstring that states a count of an outcome family (`the four outcome branches`) and lists those members names every member of that family the module references. When the module imports and exercises a fifth `OUTCOME_*` constant the summary leaves out, the count and the list both under-describe the code. The `check_docstring_cardinal_count_matches_constant_family` gate blocks this drift — a cardinal-count docstring that names two or more members of a referenced `UPPER_SNAKE` constant family, leaves at least one referenced member out, and states a count
|
|
33
|
+
- **Cardinal-count enumerations.** A docstring that states a count of an outcome family (`the four outcome branches`) and lists those members names every member of that family the module references. When the module imports and exercises a fifth `OUTCOME_*` constant the summary leaves out, the count and the list both under-describe the code. The `check_docstring_cardinal_count_matches_constant_family` gate blocks this drift — a cardinal-count docstring that names two or more members of a referenced `UPPER_SNAKE` constant family, leaves at least one referenced member out, and states a count that differs from the family size (a count above it and one below it both trip) — at Write/Edit time, on test modules as well as production modules.
|
|
34
34
|
- **Raises-clause reachability for `LargeZipFile`.** A `Raises:` clause that names `zipfile.LargeZipFile` matches a writer the body opens with ZIP64 forbidden. `zipfile` raises `LargeZipFile` only when an entry needs ZIP64 and `allowZip64` is False; a function that opens its `zipfile.ZipFile` writer in a write mode (`w`/`a`/`x`) with `allowZip64` at its default of True allows ZIP64 and never raises it, so the clause documents an unreachable exception. Drop the entry, or pass `allowZip64=False` when forbidding ZIP64 is the goal. The `check_docstring_raises_unraisable_largezipfile` gate blocks the default-ZIP64-writer form of this drift at Write/Edit time; a writer that forbids ZIP64 on any open, a read-only open, and a function that opens no writer are all left alone.
|
|
35
35
|
- **Module summary scope versus data-schema constants.** A module whose one-line docstring scopes its contents to user-facing text (`User-facing strings: CLI flag names, help text, and log messages`) names every category of constant the body holds. When the body also defines serialization field keys (`JSONL_FIELD_*`), run-metadata schema keys (`RUN_METADATA_CLI_ARG_KEY_*`), or runtime config (`STDOUT_ENCODING`, `MAIN_LOGGING_FORMAT_STRING`), the strings-only summary under-describes the module — the module-responsibility drift the repo flags. Broaden the summary to name the data-schema keys and runtime config. The `check_module_docstring_scope_omits_data_schema_constants` gate blocks this drift at Write/Edit time, and fires only when the summary claims a user-facing-text scope and names no data-schema or runtime-config category, so a summary that already names `field keys`, `schema`, or `runtime config` passes.
|
|
36
36
|
- **Field meaning: run mode versus per record.** A dataclass or `TypedDict` field documented in the class `Attributes:` block states what the field means for one record. When the code sets that field the same way for every record (a run-mode flag such as `is_dry_run = not is_execute` at each write site), the description states the run-mode meaning, not a per-record outcome. A field named `is_dry_run` documented as `True when no STP was written` reads as a per-record write result, but the value tracks the run mode, so a record that writes no file during an execute run still stores `False`. State the run-mode meaning the assignment gives the field. The `check_docstring_field_runmode_outcome` gate blocks the single-file shape of this drift at Write/Edit time — an `Attributes:` entry for a run-mode flag field (a name carrying `dry_run`) whose description carries a per-record write-outcome phrase and no run-mode phrase. The assignment that sets the field sits in another module, out of reach of that single-file gate, so any shape the gate cannot key on stays an O6 audit-lane judgment finding.
|
|
@@ -22,7 +22,7 @@ The `env_var_table_code_drift_blocker.py` hook runs on every Write, Edit, and Mu
|
|
|
22
22
|
3. Resolves the named code file under the repository root (the nearest `.git`-bearing ancestor of the markdown file) and reads its source.
|
|
23
23
|
4. Blocks the write when the file resolves yet its source never references the variable name. For an Edit, drift the file already held on an untouched row is excluded, so only drift the edit introduces is reported.
|
|
24
24
|
|
|
25
|
-
The check stays quiet for a row whose code file resolves nowhere under the repository root (it cannot prove the drift), a row whose second cell holds no code-file path, a table row inside a fenced code block
|
|
25
|
+
The check stays quiet for a row whose code file resolves nowhere under the repository root (it cannot prove the drift), a row whose second cell holds no code-file path, and a table row inside a fenced code block.
|
|
26
26
|
|
|
27
27
|
## Why this is a hook, not a lint pass
|
|
28
28
|
|
|
@@ -10,7 +10,7 @@ paths:
|
|
|
10
10
|
|
|
11
11
|
# New Production File Absent From Its Package Inventory
|
|
12
12
|
|
|
13
|
-
**When this applies:** Any Write that creates a new production code file (`.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`) in a directory whose sibling `README.md` or `CLAUDE.md` already names two or more of the directory's files in backticks, or in a skill's `scripts/` subdirectory whose parent `SKILL.md` Layout table already names two or more of those scripts.
|
|
13
|
+
**When this applies:** Any Write that creates a new production code file (`.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`) in a directory whose sibling `README.md` or `CLAUDE.md` already names two or more of the directory's files in backticks, in a directory whose own `SKILL.md` names two or more of the directory's files, or in a skill's `scripts/` subdirectory whose parent `SKILL.md` Layout table already names two or more of those scripts.
|
|
14
14
|
|
|
15
15
|
## Rule
|
|
16
16
|
|
|
@@ -23,7 +23,7 @@ Two complementary checks in `code_rules_paired_test.py` (both dispatched from `c
|
|
|
23
23
|
`check_public_function_missing_paired_test` runs on a production Python write or edit and flags a public function when all of these hold:
|
|
24
24
|
|
|
25
25
|
1. The target is production code — not a test module, hook infrastructure, config module, migration, workflow registry, or `__init__.py`.
|
|
26
|
-
2. A stem-matched test file
|
|
26
|
+
2. A stem-matched test file exists for the module — `test_<stem>.py` or `<stem>_test.py` beside the module, or `test_<stem>.py` under an ancestor `tests/` directory.
|
|
27
27
|
3. That suite already exercises the module — referencing at least one public function the module defines, or referencing one of its private (underscore-prefixed) helper functions by name — the signature of a maintained per-module suite rather than a placeholder or unrelated test file.
|
|
28
28
|
4. The public function is referenced by no test file in the directory that holds the stem-matched test.
|
|
29
29
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Re-Stage Session Edits Before Commit
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any `git commit` run through the Bash tool in a git repository.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Stage the files you edited this session right before you commit them. A file this session changed but left unstaged is dropped by a plain `git commit` — the commit records the staged snapshot and the edit stays behind in the working tree.
|
|
8
|
+
|
|
9
|
+
The `session_edit_stage_gate` hook holds you to this. It reads the per-session tracker that records every file the session edited, checks which of those files are tracked yet still unstaged at commit time, and denies the commit when any are left out. The denial names each file and gives the exact fix: `git add <paths>`, `git commit -a`, or a `# partial-commit` marker.
|
|
10
|
+
|
|
11
|
+
## What the gate allows
|
|
12
|
+
|
|
13
|
+
The gate steps aside for a commit that skips staged files on purpose:
|
|
14
|
+
|
|
15
|
+
- **`-a` / `--all`** — the commit already takes every tracked change, so nothing is dropped.
|
|
16
|
+
- **A pathspec** — `git commit -- <paths>` or `git commit <paths>` commits only the named paths on purpose.
|
|
17
|
+
- **A preceding `git add` / `git stage`** — `git add <paths> && git commit …` stages the files in its own segment before the commit runs, so they are staged by the time the commit records the index.
|
|
18
|
+
- **`# partial-commit`** — add this marker to the command to commit the staged set on purpose and leave the rest.
|
|
19
|
+
|
|
20
|
+
A `--amend` does not step the gate aside: an amend records the staged snapshot too, so an unstaged session edit is dropped the same way a plain commit drops it.
|
|
21
|
+
|
|
22
|
+
A missing tracker file or any git failure allows the commit, so the gate never blocks on a tooling problem.
|
|
23
|
+
|
|
24
|
+
## Companion hooks
|
|
25
|
+
|
|
26
|
+
- `session_file_edit_tracker` (PostToolUse) records the resolved absolute path of each Write, Edit, and MultiEdit into the per-session tracker file.
|
|
27
|
+
- `session_edit_tracker_cleanup` (SessionStart, SessionEnd) deletes the running session's own tracker file: at a fresh SessionStart so a new session begins with an empty record, and at SessionEnd so a clean exit leaves nothing behind. A tracker is read only by the session that wrote it, so a file a crashed session leaves behind is inert and no peer session touches it.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
A stale git index is a quiet failure: the commit succeeds, the branch looks right, and one file you meant to include never lands. Catching it at commit time keeps the staged set and the session's edits in step.
|
|
@@ -21,6 +21,7 @@ Shared infrastructure for the PR audit-fix loop used by `bugteam` and `pr-conver
|
|
|
21
21
|
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/write_fix_outcomes.py` | Writes the per-loop fix outcome XML into the workspace. |
|
|
22
22
|
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/preflight_worktree.py` | Verifies the working directory is a healthy worktree for the target PR's repo. |
|
|
23
23
|
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/teardown_worktrees.py` | Removes loop worktrees on clean exit. |
|
|
24
|
+
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/write_handoff.py` | Writes durable resume-handoff files under `~/.claude/runtime/pr-loop/<run-name>/` at each converge checkpoint. |
|
|
24
25
|
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/_path_resolver.py` | Resolves workspace and worktree paths from PR metadata. |
|
|
25
26
|
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/_cli_utils.py` | Shared CLI argument parsing helpers. |
|
|
26
27
|
| `packages/claude-dev-env/skills/_shared/pr-loop/scripts/_xml_utils.py` | XML serialization helpers. |
|
|
@@ -13,6 +13,7 @@ Python scripts that run the PR audit-fix loop at runtime. Both `bugteam` and `pr
|
|
|
13
13
|
| `write_fix_outcomes.py` | Writes per-loop fix outcome XML into the workspace. |
|
|
14
14
|
| `preflight_worktree.py` | Verifies the working directory is a healthy git worktree for the target PR's repo. Supports `--mode strict` to abort when the repo does not match. |
|
|
15
15
|
| `teardown_worktrees.py` | Removes per-PR worktrees after a clean loop exit. |
|
|
16
|
+
| `write_handoff.py` | Writes durable resume-handoff files under the run's `~/.claude/runtime/pr-loop` directory at each converge checkpoint. |
|
|
16
17
|
| `_path_resolver.py` | Resolves workspace and worktree paths from PR owner, repo, and number. |
|
|
17
18
|
| `_cli_utils.py` | Shared CLI argument parsing helpers (argparse wrappers). |
|
|
18
19
|
| `_xml_utils.py` | XML serialization helpers for outcome files. |
|
|
@@ -9,6 +9,7 @@ Python package of named constants for the `pr-loop` shared scripts. All constant
|
|
|
9
9
|
| `__init__.py` | Package marker. |
|
|
10
10
|
| `path_resolver_constants.py` | Path template strings and format constants: workspace directory naming, worktree directory name, diff patch filename pattern, outcome XML filename patterns, fix status values, audit constraint texts, audit category entries, fix execution steps, and fix constraint texts. |
|
|
11
11
|
| `preflight_constants.py` | Constants used by `preflight_worktree.py` for exit codes and output marker strings. |
|
|
12
|
+
| `handoff_constants.py` | Filename, path-segment, and template constants for the durable handoff writer. |
|
|
12
13
|
|
|
13
14
|
## Usage
|
|
14
15
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Filename, path-segment, and template constants for the durable handoff writer.
|
|
2
|
+
|
|
3
|
+
Consumed by `write_handoff.py`, which writes each pr-loop run's resume-handoff
|
|
4
|
+
files under `~/.claude/runtime/pr-loop/<run-name>/`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
ALL_HANDOFF_DIR_SEGMENTS = (".claude", "runtime", "pr-loop")
|
|
8
|
+
HANDOFF_JSON_FILENAME = "handoff.json"
|
|
9
|
+
HANDOFF_MARKDOWN_FILENAME = "HANDOFF.md"
|
|
10
|
+
STATE_COPY_FILENAME = "state-copy.json"
|
|
11
|
+
ATOMIC_STAGING_SUFFIX = ".tmp"
|
|
12
|
+
HANDOFF_JSON_INDENT = 2
|
|
13
|
+
COMPLETED_STEPS_SEPARATOR = ","
|
|
14
|
+
STEP_LINE_SEPARATOR = "\n"
|
|
15
|
+
|
|
16
|
+
NO_STATE_SNAPSHOT_LINE = (
|
|
17
|
+
"No state snapshot was captured. Rebuild loop state from the PR before resuming."
|
|
18
|
+
)
|
|
19
|
+
DEFAULT_NEXT_STEP_LINE = "Continue from the resume command above."
|
|
20
|
+
NO_STEPS_DONE_LINE = "- (none recorded yet)"
|
|
21
|
+
|
|
22
|
+
HANDOFF_MARKDOWN_TEMPLATE = """# Resume handoff — PR {pr_number} ({head_ref})
|
|
23
|
+
|
|
24
|
+
Goal: drive PR {pr_number} to convergence. This run stopped at phase `{phase}`.
|
|
25
|
+
|
|
26
|
+
## Resume command
|
|
27
|
+
|
|
28
|
+
Run this exactly:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
{resume_command}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## State file to trust
|
|
35
|
+
|
|
36
|
+
{state_line}
|
|
37
|
+
|
|
38
|
+
## Steps already done
|
|
39
|
+
|
|
40
|
+
{steps_block}
|
|
41
|
+
|
|
42
|
+
## Next step
|
|
43
|
+
|
|
44
|
+
{next_step}
|
|
45
|
+
"""
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Tests for write_handoff: the durable per-run handoff writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
14
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_write_handoff_module() -> ModuleType:
|
|
19
|
+
module_path = _SCRIPTS_DIR / "write_handoff.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("write_handoff", module_path)
|
|
21
|
+
assert spec is not None
|
|
22
|
+
assert spec.loader is not None
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
write_handoff = _load_write_handoff_module()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_FROZEN_TIMESTAMP = "2026-07-03T12:00:00+00:00"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _point_base_dir_at(monkeypatch: pytest.MonkeyPatch, base: Path) -> None:
|
|
35
|
+
monkeypatch.setattr(write_handoff, "_handoff_base_dir", lambda: base)
|
|
36
|
+
monkeypatch.setattr(write_handoff, "_now_iso", lambda: _FROZEN_TIMESTAMP)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_resolve_handoff_dir_joins_run_name_under_base(
|
|
40
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
41
|
+
) -> None:
|
|
42
|
+
"""resolve_handoff_dir places the run's directory under the durable base."""
|
|
43
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
44
|
+
resolved = write_handoff.resolve_handoff_dir("bugteam-pr-4")
|
|
45
|
+
assert resolved == tmp_path / "bugteam-pr-4"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_handoff_base_dir_lives_under_home_runtime_pr_loop(
|
|
49
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
50
|
+
) -> None:
|
|
51
|
+
"""The durable base sits under the user home, not the OS temp directory."""
|
|
52
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
53
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
54
|
+
base = write_handoff._handoff_base_dir()
|
|
55
|
+
assert base == tmp_path / ".claude" / "runtime" / "pr-loop"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_write_handoff_records_resume_command_and_phase(
|
|
59
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
60
|
+
) -> None:
|
|
61
|
+
"""write_handoff writes handoff.json and HANDOFF.md carrying the resume
|
|
62
|
+
command, phase, run id, completed steps, and a timestamp."""
|
|
63
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
64
|
+
handoff_dir = write_handoff.write_handoff(
|
|
65
|
+
pr_number=4,
|
|
66
|
+
head_ref="feat/pr-loop-durable-handoff",
|
|
67
|
+
phase="tick",
|
|
68
|
+
resume_command="claude --resume /pr-converge",
|
|
69
|
+
run_id="wf_abc123",
|
|
70
|
+
all_completed_steps=["preflight", "audit"],
|
|
71
|
+
note="Copilot wait-gate still open.",
|
|
72
|
+
)
|
|
73
|
+
assert handoff_dir == tmp_path / "bugteam-pr-4"
|
|
74
|
+
|
|
75
|
+
payload = json.loads((handoff_dir / "handoff.json").read_text(encoding="utf-8"))
|
|
76
|
+
assert payload["resume_command"] == "claude --resume /pr-converge"
|
|
77
|
+
assert payload["phase"] == "tick"
|
|
78
|
+
assert payload["run_id"] == "wf_abc123"
|
|
79
|
+
assert payload["completed_steps"] == ["preflight", "audit"]
|
|
80
|
+
assert payload["timestamp"] == _FROZEN_TIMESTAMP
|
|
81
|
+
|
|
82
|
+
markdown = (handoff_dir / "HANDOFF.md").read_text(encoding="utf-8")
|
|
83
|
+
assert "claude --resume /pr-converge" in markdown
|
|
84
|
+
assert "preflight" in markdown
|
|
85
|
+
assert "Copilot wait-gate still open." in markdown
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_write_handoff_copies_state_file_when_given(
|
|
89
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
90
|
+
) -> None:
|
|
91
|
+
"""A supplied --state-file is copied to state-copy.json and the copy path is
|
|
92
|
+
recorded in handoff.json."""
|
|
93
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
94
|
+
source_state = tmp_path / "loop-state.json"
|
|
95
|
+
source_state.write_text('{"loop_count": 3}', encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
handoff_dir = write_handoff.write_handoff(
|
|
98
|
+
pr_number=4,
|
|
99
|
+
head_ref="feat/branch",
|
|
100
|
+
phase="teardown",
|
|
101
|
+
resume_command="claude --resume /autoconverge",
|
|
102
|
+
state_file=source_state,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
state_copy = handoff_dir / "state-copy.json"
|
|
106
|
+
assert json.loads(state_copy.read_text(encoding="utf-8")) == {"loop_count": 3}
|
|
107
|
+
payload = json.loads((handoff_dir / "handoff.json").read_text(encoding="utf-8"))
|
|
108
|
+
assert payload["state_file"] == state_copy.as_posix()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_write_handoff_omits_state_copy_when_absent(
|
|
112
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
113
|
+
) -> None:
|
|
114
|
+
"""With no --state-file, no state-copy.json is written and state_file is null."""
|
|
115
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
116
|
+
handoff_dir = write_handoff.write_handoff(
|
|
117
|
+
pr_number=4,
|
|
118
|
+
head_ref="feat/branch",
|
|
119
|
+
phase="tick",
|
|
120
|
+
resume_command="claude --resume /pr-converge",
|
|
121
|
+
)
|
|
122
|
+
assert not (handoff_dir / "state-copy.json").exists()
|
|
123
|
+
payload = json.loads((handoff_dir / "handoff.json").read_text(encoding="utf-8"))
|
|
124
|
+
assert payload["state_file"] is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_parse_arguments_reads_completed_steps_value() -> None:
|
|
128
|
+
"""The CLI captures the raw comma-separated --completed-steps value."""
|
|
129
|
+
arguments = write_handoff.parse_arguments(
|
|
130
|
+
[
|
|
131
|
+
"--pr-number",
|
|
132
|
+
"4",
|
|
133
|
+
"--head-ref",
|
|
134
|
+
"feat/branch",
|
|
135
|
+
"--phase",
|
|
136
|
+
"tick",
|
|
137
|
+
"--resume-command",
|
|
138
|
+
"claude --resume /pr-converge",
|
|
139
|
+
"--completed-steps",
|
|
140
|
+
"preflight,audit,fix",
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
assert arguments.completed_steps == "preflight,audit,fix"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_main_writes_handoff_and_prints_dir(
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
149
|
+
capsys: pytest.CaptureFixture[str],
|
|
150
|
+
) -> None:
|
|
151
|
+
"""main writes the handoff files and prints the durable directory path."""
|
|
152
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
153
|
+
exit_code = write_handoff.main(
|
|
154
|
+
[
|
|
155
|
+
"--pr-number",
|
|
156
|
+
"4",
|
|
157
|
+
"--head-ref",
|
|
158
|
+
"feat/branch",
|
|
159
|
+
"--phase",
|
|
160
|
+
"tick",
|
|
161
|
+
"--resume-command",
|
|
162
|
+
"claude --resume /pr-converge",
|
|
163
|
+
"--completed-steps",
|
|
164
|
+
"preflight,audit",
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
assert exit_code == 0
|
|
168
|
+
printed = capsys.readouterr().out.strip()
|
|
169
|
+
assert "\\" not in printed
|
|
170
|
+
assert printed.endswith("bugteam-pr-4")
|
|
171
|
+
payload = json.loads(
|
|
172
|
+
(tmp_path / "bugteam-pr-4" / "handoff.json").read_text(encoding="utf-8")
|
|
173
|
+
)
|
|
174
|
+
assert payload["completed_steps"] == ["preflight", "audit"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_main_returns_one_on_non_utf8_state_file(
|
|
178
|
+
tmp_path: Path,
|
|
179
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
180
|
+
capsys: pytest.CaptureFixture[str],
|
|
181
|
+
) -> None:
|
|
182
|
+
"""main exits 1 rather than crashing when --state-file holds non-UTF-8 bytes."""
|
|
183
|
+
_point_base_dir_at(monkeypatch, tmp_path)
|
|
184
|
+
bad_state = tmp_path / "loop-state.json"
|
|
185
|
+
bad_state.write_bytes(b"\xff\xfe not valid utf-8")
|
|
186
|
+
exit_code = write_handoff.main(
|
|
187
|
+
[
|
|
188
|
+
"--pr-number",
|
|
189
|
+
"4",
|
|
190
|
+
"--head-ref",
|
|
191
|
+
"feat/branch",
|
|
192
|
+
"--phase",
|
|
193
|
+
"teardown",
|
|
194
|
+
"--resume-command",
|
|
195
|
+
"claude --resume /autoconverge",
|
|
196
|
+
"--state-file",
|
|
197
|
+
str(bad_state),
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
assert exit_code == 1
|
|
201
|
+
assert "write_handoff failed" in capsys.readouterr().err
|