claude-dev-env 1.85.0 → 1.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +142 -7
  3. package/_shared/pr-loop/scripts/terminology_sweep.py +288 -92
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
  5. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +180 -91
  6. package/agents/code-quality-agent.md +1 -1
  7. package/hooks/blocking/CLAUDE.md +1 -0
  8. package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
  9. package/hooks/blocking/session_edit_stage_gate.py +654 -0
  10. package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
  11. package/hooks/hooks.json +30 -0
  12. package/hooks/hooks_constants/CLAUDE.md +2 -0
  13. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
  14. package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
  15. package/hooks/lifecycle/CLAUDE.md +1 -1
  16. package/hooks/observability/CLAUDE.md +2 -0
  17. package/hooks/observability/session_file_edit_tracker.py +224 -0
  18. package/hooks/observability/test_session_file_edit_tracker.py +174 -0
  19. package/hooks/session/CLAUDE.md +6 -2
  20. package/hooks/session/session_edit_tracker_cleanup.py +120 -0
  21. package/hooks/session/task_list_loop_starter.py +36 -0
  22. package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
  23. package/hooks/session/test_task_list_loop_starter.py +53 -0
  24. package/package.json +1 -1
  25. package/rules/CLAUDE.md +1 -0
  26. package/rules/re-stage-before-commit.md +31 -0
  27. package/skills/autoconverge/SKILL.md +4 -4
  28. package/skills/autoconverge/reference/gotchas.md +3 -2
  29. package/skills/autoconverge/reference/stop-conditions.md +22 -6
  30. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +528 -1
  31. package/skills/autoconverge/workflow/converge.contract.test.mjs +29 -21
  32. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +77 -8
  33. package/skills/autoconverge/workflow/converge.mjs +456 -59
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.85.0",
3
+ "version": "1.86.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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 |
@@ -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.
@@ -476,10 +476,10 @@ and `jl-cmd/claude-code-config` for rules and skills both appear):
476
476
  2. **Converge the generation.** Launch `workflow/converge_multi.mjs` with one
477
477
  entry per checked-out deferred PR, exactly as the
478
478
  [multi-PR launch](#launch-the-multi-pr-workflow) describes. Each child run
479
- re-checks Copilot and Bugbot availability every round through the workflow's own
480
- pre-spawn probe, so a reviewer that is down or out of quota is never spawned in any
481
- generation; the `copilotDisabled`/`bugbotDisabled` flags each deferred PR carries
482
- seed that check for the first round.
479
+ checks Copilot and Bugbot availability through the workflow's own preflight-git
480
+ probe, carried across rounds, so a reviewer that is down or out of quota is never
481
+ spawned in any generation; the `copilotDisabled`/`bugbotDisabled` flags each
482
+ deferred PR carries seed that check for the first round.
483
483
  3. **Tear down.** Run the [multi-PR teardown](#multi-pr-teardown-on-workflow-completion)
484
484
  over the generation's `results`, and revoke project permissions once per
485
485
  repository.
@@ -22,8 +22,9 @@ fails in a new way.
22
22
  bug-audit lenses both diff against `origin/main`. Concurrent `git fetch` calls
23
23
  contend on the worktree `.git` lock and fail intermittently, so the workflow
24
24
  runs a single serial `git fetch origin main` inside the merged `preflight-git`
25
- step — the one git-utility agent that also resolves the PR HEAD SHA and probes
26
- mergeability and the parallel lenses run no git fetch of their own; they
25
+ step — the one git-utility agent that also resolves the PR HEAD SHA, probes
26
+ mergeability, and checks Copilot and Bugbot availability and the parallel
27
+ lenses run no git fetch of their own; they
27
28
  diff against the already-current ref. The workflow threads the resolved HEAD
28
29
  through the rounds and re-runs `preflight-git` only after a push or rebase
29
30
  invalidates it, so a round on an unchanged HEAD spawns no git agent at all.
@@ -36,8 +36,22 @@ skill still runs teardown (revoke permissions, final report).
36
36
  denied, errors, or its agent dies). The convergence gate's bugteam-review
37
37
  check can never pass without that CLEAN review, so the run stops rather than
38
38
  re-converge to the iteration cap. The `blocker` names the post failure and the
39
- HEAD. Unblock by allowing `post_audit_thread.py` with a Bash permission rule,
40
- or post the CLEAN review by hand, then re-run.
39
+ HEAD. Unblock a permission denial by allowing `post_audit_thread.py` with a
40
+ Bash permission rule. Unblock any other failure by creating a fresh temporary
41
+ file whose exact content is an empty JSON array (`[]`) and re-running the run's
42
+ own `post_audit_thread.py --skill bugteam --state CLEAN` command for the
43
+ lens-verified HEAD with that file as `--findings-json`; then re-run the
44
+ workflow. A CLEAN post carries an empty findings array by construction, so this
45
+ re-issues the run's own command for the outcome the lenses already established
46
+ for that HEAD.
47
+ - **No review lens reviewed HEAD** — a round can end with no lens having reviewed
48
+ the HEAD three ways: the preflight resolves no SHA, every lens agent dies, or
49
+ every lens is down or disabled. A single such round retries on the next round.
50
+ Three consecutive no-lens-reviewed rounds (any mix of the three causes) reach
51
+ `CONFIG.maxConsecutiveNoLensRounds` and stop the run with a `blocker` that names
52
+ the consecutive count and only the causes that actually occurred, rather than
53
+ looping to the iteration cap. Any round in which at least one lens reviews the
54
+ HEAD resets the consecutive count.
41
55
 
42
56
  ## Not a blocker (the run continues)
43
57
 
@@ -58,10 +72,12 @@ skill still runs teardown (revoke permissions, final report).
58
72
  Bugbot lens (null result) counts as down for that HEAD, so the convergence
59
73
  check runs with `--bugbot-down` rather than demanding a Bugbot verdict the
60
74
  dead agent never produced.
61
- - **Every lens agent dies** — when all three parallel lenses return null in the
62
- same round, the round is a failure, not a clean: the workflow posts no CLEAN
63
- bugteam artifact and does not advance to the Copilot gate. It re-resolves HEAD
64
- and retries on the next round, still bounded by the iteration cap.
75
+ - **Every lens agent dies (a single round)** — when all three parallel lenses
76
+ return null in the same round, the round is a failure, not a clean: the
77
+ workflow posts no CLEAN bugteam artifact and does not advance to the Copilot
78
+ gate. It re-resolves HEAD and retries on the next round. This is a
79
+ no-lens-reviewed round, so consecutive occurrences are bounded by the
80
+ no-review-lens cap in the blockers above, not just the iteration cap.
65
81
 
66
82
  ## User stop
67
83