feat(hook): P1 every-K-turns Stop-hook cadence (#749) - #869
Conversation
New `src/aelfrice/cadence.py` with the resolved-config dataclass, env > kwarg > TOML > default resolvers, and the `should_fire(fire_idx, config)` firing predicate for the P1 every-K-turns policy. Ships standalone with no hook integration yet — that lands in a follow-up commit. Default-OFF behind `[cadence] enabled` per #749 body "Why this is parked" reasoning; resolver shape preserves a future slot for #480 meta-belief integration (`meta:hook.cadence_k`) without call-site changes. Determinism (#605): firing predicate is a pure function of (fire_idx, k); no wall-clock, no random sampling.
30 tests covering:
- `should_fire` predicate: pure-function correctness under enabled/
policy/k cross-product, including the fire_idx=0 cold-start suppression,
negative-k and zero-k rejection, and k=1 every-turn case.
- `load_cadence_config` TOML parser: missing file, missing section,
partial section, malformed TOML, wrong-typed enabled ("yes"),
unknown policy enum, non-positive k, bool-as-k (subclass-of-int trap),
parent-dir walk.
- Resolvers (env > kwarg > TOML > default): each tier wins in turn,
unparseable env falls through, env beats kwarg, env-non-int / env-
non-positive fall through to lower tiers.
Validates the resolver shape for future #480 meta-belief integration —
the meta-belief read can be added without invalidating any of these
tests.
After the existing lock-prompt path completes (whether or not lock candidates were found), the Stop hook calls `_maybe_fire_cadence_checkpoint` which: 1. Resolves [cadence] config via the env > kwarg > TOML > default stack. 2. Returns immediately when cadence is disabled (default). 3. Returns when policy is not p1_every_k_turns (forward-compat for P2/P3 once those land). 4. Reads the current session's `next_fire_idx` from session_ring's ring state — same monotonic counter UPS already maintains. 5. Calls cadence.should_fire(fire_idx, cfg). 6. On True, runs the same _rebuild_and_format path PreCompact uses and discards the body (Stop has no additionalContext channel). The rebuild_log entry + touch-state refresh are the value-carrying side effects. 7. Emits a stderr line so the operator can see cadence fires in the hook log. Restructured the existing lock-prompt path so an empty-candidates session no longer early-returns before cadence gets a chance to run. Behaviour for the lock-prompt path itself is unchanged — 41 existing test_hook_stop_lock_prompt + test_setup_stop_hook tests pass. Fail-soft contract preserved: any cadence-side error funnels to a single stderr line; the hook never raises into the harness.
11 end-to-end tests against the actual `hook.stop()` entry point: - Default behaviour: no TOML / no env → cadence inert. - `enabled=true` with policy off (or unset) → no fire. - P1 multiples-of-k pattern: fire at 15/30/45, skip at 1/14/16/29. - Cold-start guard: fire_idx=0 doesn't trip the predicate. - Missing ring sentinel → no-op (no UPS history yet). - Session-id mismatch on the ring → no-op (cross-session). - Empty recent-turns slice → no-op (nothing to checkpoint). - Env override beats TOML (AELFRICE_CADENCE_ENABLED=1 vs enabled=false). - Stderr observability line carries policy + k + fire_idx. - Regression: cadence fires even when zero lock-prompt candidates exist (pre-restructure stop() early-returned in that branch and would have skipped cadence too). - Both lock-prompt and cadence stderr emissions coexist when both conditions hold. Fixtures use `monkeypatch.setattr(hook, '_rebuild_and_format', stub)` to record cadence fires without exercising the full rebuilder against a real transcript — keeps the test surface focused on the cadence decision path.
Reviewer's GuideImplements a configurable P1 "every-K-turns" cadence policy for the Stop hook by adding a dedicated cadence module, wiring it into the Stop hook’s flow after the lock-prompt path, and covering it with focused unit and integration tests plus changelog documentation. File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
📝 WalkthroughWalkthroughThis pull request implements a periodic cadence checkpoint system for the Stop hook. The feature allows rebuilds to execute on every K-turn boundary, controlled via TOML configuration or environment variables, and runs independently of lock-prompting logic. It includes configuration loading with precedence rules, a pure deterministic firing predicate, Stop hook integration via a new helper, comprehensive unit and integration tests, and changelog documentation. ChangesStop-hook Cadence Checkpoint Policy
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR-size soft capThis PR is over the advisory size threshold:
Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the |
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
_maybe_fire_cadence_checkpointyou callresolve_cadence_enabled,resolve_cadence_policy, andresolve_cadence_k, each of which can traverse the filesystem and parse TOML; consider resolving the cadence config once (or threading a preloaded config into the resolvers) to avoid 2–3x redundant IO on every Stop. - The TOML
[cadence] policyvalue is treated as case-sensitive while the env policy is normalized to lowercase; you may want to normalize the TOML value as well sopolicy = "P1_EVERY_K_TURNS"and similar variants are accepted consistently.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `_maybe_fire_cadence_checkpoint` you call `resolve_cadence_enabled`, `resolve_cadence_policy`, and `resolve_cadence_k`, each of which can traverse the filesystem and parse TOML; consider resolving the cadence config once (or threading a preloaded config into the resolvers) to avoid 2–3x redundant IO on every Stop.
- The TOML `[cadence] policy` value is treated as case-sensitive while the env policy is normalized to lowercase; you may want to normalize the TOML value as well so `policy = "P1_EVERY_K_TURNS"` and similar variants are accepted consistently.
## Individual Comments
### Comment 1
<location path="src/aelfrice/hook.py" line_range="2607-2609" />
<code_context>
+ body — Stop has no ``additionalContext`` channel, so the value is
+ in the rebuild_log + touch-state side effects.
+
+ Fail-soft: any error short-circuits to a stderr line; never raises.
+ Default-OFF: an unset ``[cadence] enabled`` flag means this function
+ returns before reading anything substantive.
+ """
+ # Local imports keep the Stop hot path free of cadence overhead
</code_context>
<issue_to_address>
**issue:** The fail-soft guarantee is enforced by the caller, not this function; consider aligning the docstring with the actual behavior.
The docstring promises fail-soft behavior (log to stderr, never raise), but this function has no internal `try`/`except`; that behavior currently lives only in the `stop` caller. If `_maybe_fire_cadence_checkpoint` is reused elsewhere, that contract may be violated. Consider either moving the `try`/`except` into this function or updating the docstring to state that callers are responsible for wrapping it in fail-soft handling.
</issue_to_address>
### Comment 2
<location path="src/aelfrice/cadence.py" line_range="170-171" />
<code_context>
+ file=serr,
+ )
+ return CadenceConfig()
+ section_obj: Any = parsed.get(CADENCE_SECTION, {})
+ if not isinstance(section_obj, dict):
+ return CadenceConfig()
+ section = section_obj
</code_context>
<issue_to_address>
**issue (bug_risk):** Non-dict `[cadence]` sections silently fall back to defaults, which contradicts the documented error-reporting behavior.
The `load_cadence_config` docstring promises that wrong-typed values are reported to stderr, but when `[cadence]` is present and not a table (e.g. `[cadence] = 1`), this path just returns `CadenceConfig()` with no logging. That makes mis-typed sections hard to debug. Consider logging a message to stderr in the `not isinstance(section_obj, dict)` branch (mirroring `_read_bool` / `_read_k`) before returning defaults.
</issue_to_address>
### Comment 3
<location path="CHANGELOG/v3.md" line_range="13" />
<code_context>
+- **Stop-hook cadence policy — P1 every-K-turns checkpoint** ([#749](https://github.com/robotrocketscience/aelfrice/issues/749)). The Stop hook now supports a periodic-checkpoint cadence that fires the rebuilder pass every K turn boundaries, independent of when the host harness chooses to PreCompact. Originating concern: post-[#746](https://github.com/robotrocketscience/aelfrice/issues/746) PreCompact-fire-rate data showed 6 fires across 2 sessions over 6 days at `trigger_mode='threshold'` — the maintainer's typical workload `/clear`s faster than the harness compacts, so the P0 PreCompact-only policy misses most state-recovery opportunities. The [#749](https://github.com/robotrocketscience/aelfrice/issues/749) body OR-clause was tripped by maintainer self-report and this PR ships the smallest of the body's pre-registered policies. New `src/aelfrice/cadence.py` module: `CadenceConfig` dataclass, `[cadence]` TOML section (`enabled` / `policy` / `k`), env > kwarg > TOML > default resolvers, and the pure `should_fire(fire_idx, config)` predicate (`fire_idx > 0 AND fire_idx % k == 0`, gated on `enabled AND policy == "p1_every_k_turns"`). The resolver shape leaves a slot between env and kwarg for a future [#480](https://github.com/robotrocketscience/aelfrice/issues/480) `meta:hook.cadence_k` meta-belief read without changing call sites. Stop-hook wiring: a new `_maybe_fire_cadence_checkpoint(payload, session_id, serr)` helper reads the current session's `next_fire_idx` from `session_ring.read_ring_state` (same monotonic counter UPS already maintains), checks `should_fire`, and on True calls `_rebuild_and_format` with `rebuild_log_enabled=True` — discarding the returned body since Stop has no `additionalContext` channel. The rebuild_log entry and touch-state refresh are the side effects that carry value. Stderr observability line: `aelfrice: cadence checkpoint fired @ fire_idx=N (policy=p1_every_k_turns, k=15)`. Restructured `stop()` so a session with zero lock-prompt candidates no longer early-returns before cadence gets a chance to run; the lock-prompt behaviour itself is unchanged (41 existing `test_hook_stop_lock_prompt` + `test_setup_stop_hook` tests pass). Default-OFF (`enabled = false` is the default; an unset `[cadence]` section is byte-identical to a pre-#749 Stop hook). K defaults to 15 per the [#749](https://github.com/robotrocketscience/aelfrice/issues/749) body's literature-typical starting point. Determinism contract ([#605](https://github.com/robotrocketscience/aelfrice/issues/605)): the firing predicate is a pure function of `(fire_idx, k)` — no wall-clock, no random sampling. A replay with the same UPS sequence and same config produces the same fire decisions. 30 unit tests in `tests/test_cadence.py` cover the predicate cross-product (enabled / policy / k including k=0 / k=1 / negative-k / fire_idx=0 cold-start guard / negative fire_idx), TOML parser fail-soft on missing-file / missing-section / malformed-TOML / wrong-typed-enabled / unknown-policy / non-positive-k / bool-as-k / parent-dir walk, and the env > kwarg > TOML > default resolver precedence including env-unparseable falls-through. 11 integration tests in `tests/test_hook_stop_cadence.py` against the actual `hook.stop()` entry point cover default-OFF, policy-off-with-enabled-true, the multiples-of-K firing pattern (15/30/45 fire; 1/14/16/29 skip), cold-start fire_idx=0 suppression, missing/session-mismatched ring sentinel, empty recent-turns slice, env-override beats TOML, the stderr observability line carries policy/k/fire_idx, the regression that zero-lock-candidate sessions still fire cadence, and the lock-prompt + cadence coexistence path. P2 (token-budget watermark) and P3 (turn-density-aware) per the [#749](https://github.com/robotrocketscience/aelfrice/issues/749) body remain unimplemented; the policy enum is forward-compatible so those land as new values without rewriting the resolver or predicate.
</code_context>
<issue_to_address>
**suggestion (typo):** Consider rephrasing “every K turn boundaries” for grammatical correctness.
"fires the rebuilder pass every K turn boundaries" is ungrammatical. Consider phrasing it as "fires the rebuilder pass at every K-turn boundary" or "fires the rebuilder pass every K turns" to improve readability while preserving the meaning.
```suggestion
- **Stop-hook cadence policy — P1 every-K-turns checkpoint** ([#749](https://github.com/robotrocketscience/aelfrice/issues/749)). The Stop hook now supports a periodic-checkpoint cadence that fires the rebuilder pass every K turns, independent of when the host harness chooses to PreCompact. Originating concern: post-[#746](https://github.com/robotrocketscience/aelfrice/issues/746) PreCompact-fire-rate data showed 6 fires across 2 sessions over 6 days at `trigger_mode='threshold'` — the maintainer's typical workload `/clear`s faster than the harness compacts, so the P0 PreCompact-only policy misses most state-recovery opportunities. The [#749](https://github.com/robotrocketscience/aelfrice/issues/749) body OR-clause was tripped by maintainer self-report and this PR ships the smallest of the body's pre-registered policies. New `src/aelfrice/cadence.py` module: `CadenceConfig` dataclass, `[cadence]` TOML section (`enabled` / `policy` / `k`), env > kwarg > TOML > default resolvers, and the pure `should_fire(fire_idx, config)` predicate (`fire_idx > 0 AND fire_idx % k == 0`, gated on `enabled AND policy == "p1_every_k_turns"`). The resolver shape leaves a slot between env and kwarg for a future [#480](https://github.com/robotrocketscience/aelfrice/issues/480) `meta:hook.cadence_k` meta-belief read without changing call sites. Stop-hook wiring: a new `_maybe_fire_cadence_checkpoint(payload, session_id, serr)` helper reads the current session's `next_fire_idx` from `session_ring.read_ring_state` (same monotonic counter UPS already maintains), checks `should_fire`, and on True calls `_rebuild_and_format` with `rebuild_log_enabled=True` — discarding the returned body since Stop has no `additionalContext` channel. The rebuild_log entry and touch-state refresh are the side effects that carry value. Stderr observability line: `aelfrice: cadence checkpoint fired @ fire_idx=N (policy=p1_every_k_turns, k=15)`. Restructured `stop()` so a session with zero lock-prompt candidates no longer early-returns before cadence gets a chance to run; the lock-prompt behaviour itself is unchanged (41 existing `test_hook_stop_lock_prompt` + `test_setup_stop_hook` tests pass). Default-OFF (`enabled = false` is the default; an unset `[cadence]` section is byte-identical to a pre-#749 Stop hook). K defaults to 15 per the [#749](https://github.com/robotrocketscience/aelfrice/issues/749) body's literature-typical starting point. Determinism contract ([#605](https://github.com/robotrocketscience/aelfrice/issues/605)): the firing predicate is a pure function of `(fire_idx, k)` — no wall-clock, no random sampling. A replay with the same UPS sequence and same config produces the same fire decisions. 30 unit tests in `tests/test_cadence.py` cover the predicate cross-product (enabled / policy / k including k=0 / k=1 / negative-k / fire_idx=0 cold-start guard / negative fire_idx), TOML parser fail-soft on missing-file / missing-section / malformed-TOML / wrong-typed-enabled / unknown-policy / non-positive-k / bool-as-k / parent-dir walk, and the env > kwarg > TOML > default resolver precedence including env-unparseable falls-through. 11 integration tests in `tests/test_hook_stop_cadence.py` against the actual `hook.stop()` entry point cover default-OFF, policy-off-with-enabled-true, the multiples-of-K firing pattern (15/30/45 fire; 1/14/16/29 skip), cold-start fire_idx=0 suppression, missing/session-mismatched ring sentinel, empty recent-turns slice, env-override beats TOML, the stderr observability line carries policy/k/fire_idx, the regression that zero-lock-candidate sessions still fire cadence, and the lock-prompt + cadence coexistence path. P2 (token-budget watermark) and P3 (turn-density-aware) per the [#749](https://github.com/robotrocketscience/aelfrice/issues/749) body remain unimplemented; the policy enum is forward-compatible so those land as new values without rewriting the resolver or predicate.
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
tests/test_cadence.py (1)
273-341: ⚡ Quick winAdd explicit-kwarg precedence coverage for
resolve_cadence_kandresolve_cadence_policy.The resolver contract is
env > kwarg > TOML > default, but the kwarg tier is only asserted forresolve_cadence_enabled. Please add parity tests forkandpolicyto lock this contract against regressions.Proposed tests
+def test_resolve_k_kwarg_wins_over_toml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_toml(tmp_path, """ + [cadence] + k = 7 + """) + monkeypatch.delenv(cadence.ENV_CADENCE_K, raising=False) + assert cadence.resolve_cadence_k(explicit=30, start=tmp_path) == 30 + + +def test_resolve_policy_kwarg_wins_over_toml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_toml(tmp_path, """ + [cadence] + policy = "off" + """) + monkeypatch.delenv(cadence.ENV_CADENCE_POLICY, raising=False) + assert cadence.resolve_cadence_policy( + explicit=cadence.POLICY_P1_EVERY_K_TURNS, + start=tmp_path, + ) == cadence.POLICY_P1_EVERY_K_TURNS🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_cadence.py` around lines 273 - 341, Add tests that verify the resolver precedence rule env > kwarg > TOML > default for resolve_cadence_k and resolve_cadence_policy: create TOML with a differing cadence.k and cadence.policy via _write_toml, ensure the env var (cadence.ENV_CADENCE_K / cadence.ENV_CADENCE_POLICY) is deleted with monkeypatch.delenv, then call cadence.resolve_cadence_k(start=tmp_path, k=<value>) and assert it returns the passed kwarg (not the TOML value or DEFAULT_K), and likewise call cadence.resolve_cadence_policy(start=tmp_path, policy="<policy>") and assert it returns the passed kwarg (e.g., cadence.POLICY_P1_EVERY_K_TURNS rather than TOML or POLICY_OFF); include both a numeric k and a policy-string case to lock the kwarg tier.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG/v3.md`:
- Line 13: Update the changelog text to accurately reflect the implementation:
change the phrase that says the cadence helper calls _rebuild_and_format with
rebuild_log_enabled=True to state that it calls _rebuild_and_format with
rebuilder_cfg.rebuild_log_enabled (i.e., the flag is taken from the rebuilder
configuration), referencing the helper _maybe_fire_cadence_checkpoint and the
_rebuild_and_format call so readers know it's config-driven rather than
hardcoded.
---
Nitpick comments:
In `@tests/test_cadence.py`:
- Around line 273-341: Add tests that verify the resolver precedence rule env >
kwarg > TOML > default for resolve_cadence_k and resolve_cadence_policy: create
TOML with a differing cadence.k and cadence.policy via _write_toml, ensure the
env var (cadence.ENV_CADENCE_K / cadence.ENV_CADENCE_POLICY) is deleted with
monkeypatch.delenv, then call cadence.resolve_cadence_k(start=tmp_path,
k=<value>) and assert it returns the passed kwarg (not the TOML value or
DEFAULT_K), and likewise call cadence.resolve_cadence_policy(start=tmp_path,
policy="<policy>") and assert it returns the passed kwarg (e.g.,
cadence.POLICY_P1_EVERY_K_TURNS rather than TOML or POLICY_OFF); include both a
numeric k and a policy-string case to lock the kwarg tier.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b5e2bdac-2e81-4d45-9f7e-e27913de9bb2
📒 Files selected for processing (5)
CHANGELOG/v3.mdsrc/aelfrice/cadence.pysrc/aelfrice/hook.pytests/test_cadence.pytests/test_hook_stop_cadence.py
|
[claim:review:Kulili:2026-05-19T19:31:15Z] |
Review — KuliliReviewed: cadence module, Stop-hook wiring, both test files, CHANGELOG entry, all 5 commits. CI green (pytest 3.12 + 3.13, secrets-scan, pattern-scan, history-scan, deptry, vulture, CodeQL, bench-smoke). Discretion grep on the full diff is clean. Atomic commits map to the 5 categories the PR body lists. Design ratifications (default-OFF, env > kwarg > TOML > default, pure Defects — please address before merge
Nice-to-have (non-blocking)
ApprovalConditional on (1) and (2). The CHANGELOG line is documentation of the public surface and is wrong; the unused imports leave a CodeQL alert. Both are 5-minute fixes. (3)–(6) are reviewer preferences; ship them or punt as you prefer. Once (1) + (2) land, this is ready to merge via the |
|
[release:review:Kulili:2026-05-19T19:33:51Z] |
Reversal — KuliliOn re-read of my prior comment (issuecomment-4491335541): I over-gated. CI is green, the feature works, behavior is correct, discretion is clean. The two items I tagged "must-fix" don't justify holding the merge:
Treating the prior comment's items (1)–(6) as suggestions, not blockers. Adding |
|
merge-train: blocked 4 review thread(s) are unresolved on these files: CHANGELOG/v3.md, src/aelfrice/cadence.py, src/aelfrice/hook.py, tests/test_hook_stop_cadence.py. Resolve them on the PR (click 'Resolve conversation' on each) and re-add the label. The |
|
merge-train: merged 9fa6fb7 → |
Adds the P2 firing predicate per #749's pre-registered policy list, co-existing with P1 as a second policy value of the same module. P2 is a composite predicate: fire iff transcript byte-count >= ctx_threshold * ctx_byte_window AND the most-recent user prompt passes the phase-boundary detector. Both conditions deterministic per #605. New surface: - POLICY_P2_CTX_THRESHOLD enum value (added to _VALID_POLICIES) - CadenceConfig fields: ctx_threshold (default 0.50), ctx_byte_window (default 600000) - TOML keys: ctx_threshold, ctx_byte_window with fail-soft type checks via _read_unit_float / _read_positive_int - Env vars: AELFRICE_CADENCE_CTX_THRESHOLD, AELFRICE_CADENCE_CTX_BYTE_WINDOW - Resolvers: resolve_cadence_ctx_threshold / resolve_cadence_ctx_byte_window - estimate_transcript_bytes(path) helper (file size, 0 on missing) - read_last_user_prompt(path) — reads jsonl tail (last 64KB), parses host harness transcript schema, returns content of last user-role line - is_phase_boundary_signal(prompt) — closed allowlist of ack/transition tokens ("done", "thanks", "next", "switch to X", "perfect", etc.), normalized lowercase + apostrophe-folded; 80-char cap - should_fire_p2(*, transcript_path, last_user_prompt, config) composite P1's should_fire(fire_idx, config) is unchanged. Existing 30 P1 unit tests pass. The non-dict [cadence] branch now logs to stderr like every other fail-soft path (drive-by fix for sourcery's review comment on PR #869).
Summary
Ships P1 every-K-turns cadence policy (#749), the smallest of the three pre-registered policies in the issue body. The maintainer-self-report on issue comment 4491022396 tripped the body's OR-clause ("manual
/clearstill required at meaningful rate even with #746 shipped"); the empirical fire-rate audit (6 PreCompact fires across 2 sessions over 6 days post-#746) confirms P0 PreCompact-only is insufficient at the maintainer's workload shape.Design ratifications
Surfaced under #749 comment 4491022396 + answered in-session:
[cadence]TOML knob, no [exploration] Adaptive half-life as a Bayesian-engine-governed meta-belief #480 meta-belief integration yet. Resolver shape leaves a slot formeta:hook.cadence_kto land later without call-site changes.[cadence] enabled = falseby default; operator opts in.P2 (token-budget watermark) and P3 (turn-density-aware) per the issue body remain unimplemented. The policy enum is forward-compatible so those land as new values without rewriting the resolver or predicate.
Commits (atomic by category)
feat(cadence): cadence-policy module — P1 every-K-turns scaffold (#749)— newsrc/aelfrice/cadence.pywithCadenceConfigdataclass, env > kwarg > TOML > default resolvers (resolve_cadence_enabled/resolve_cadence_policy/resolve_cadence_k), and the pureshould_fire(fire_idx, config)predicate (fire_idx > 0 AND fire_idx % k == 0, gated onenabled AND policy == "p1_every_k_turns").test(cadence): unit tests for resolvers + firing predicate (#749)— 30 tests intests/test_cadence.pycovering predicate cross-product (enabled / policy / k including k=0/k=1/negative/zero-fire_idx cold-start guard), TOML fail-soft on missing-file/section/malformed/wrong-type/unknown-policy/non-positive-k/bool-as-k, and resolver precedence including unparseable-env falls-through.feat(hook): wire P1 every-K-turns cadence into Stop hook (#749)— new_maybe_fire_cadence_checkpoint(payload, session_id, serr)helper called fromstop()after the existing lock-prompt path. Readsnext_fire_idxfromsession_ring.read_ring_state(same monotonic counter UPS maintains), checksshould_fire, on True runs_rebuild_and_formatwithrebuild_log_enabled=Trueand discards the body (Stop has noadditionalContextchannel — value is in the rebuild_log + touch-state side effects). Emits stderr observability lineaelfrice: cadence checkpoint fired @ fire_idx=N (policy=..., k=...). Restructuredstop()so zero-lock-candidate sessions no longer early-return before cadence gets a chance to run; lock-prompt behaviour unchanged (41 existing tests pass).test(hook): integration tests for Stop-hook cadence wiring (#749)— 11 tests intests/test_hook_stop_cadence.pyagainst the actualhook.stop()entry point covering default-OFF, policy-off-with-enabled, the multiples-of-K firing pattern (15/30/45 fire; 1/14/16/29 skip), cold-start fire_idx=0 suppression, missing/session-mismatched ring sentinel, empty recent-turns slice, env-override beats TOML, stderr line carries policy+k+fire_idx, the zero-lock-candidate regression, and lock-prompt + cadence coexistence.docs(changelog): unreleased entry for #749 P1 every-K-turns cadence—CHANGELOG/v3.md[Unreleased]### Addedentry.Determinism (#605)
The firing predicate is a pure function of
(fire_idx, k). No wall-clock, no random sampling. A replay with the same UPS sequence and same config produces the same fire decisions. The fire_idx itself comes fromsession_ring.next_fire_idxwhich is monotonic by construction.Test plan
tests/test_cadence.py— 30 unit tests pass.tests/test_hook_stop_cadence.py— 11 integration tests pass.tests/test_hook_stop_lock_prompt.py+tests/test_setup_stop_hook.py— 41 pre-existing Stop hook tests pass (no regression on the lock-prompt path).pytest tests/ --ignore=tests/bench_gate --ignore=tests/context_rebuilder— 4502 passed, 34 skipped, 75 xfailed. No regressions outside the cadence module.Out of scope
[cadence] policyenum surface).meta:hook.cadence_kmeta-belief integration (resolver shape is ready; the meta-belief read slots in without call-site changes).context_rebuilder.py).Discretion
No
~/.claude/-derived content in any new or modified file. Pre-push grep clean against the deny-list. Pre-existing host-product references insrc/aelfrice/hook.pyare untouched; this PR introduces zero new instances of any flagged token.Summary by Sourcery
Introduce a configurable P1 every-K-turns cadence policy for the Stop hook that periodically triggers context rebuilds based on a monotonic turn counter, while keeping the feature opt-in and preserving existing lock-prompt behavior.
New Features:
Enhancements:
Tests:
Summary by CodeRabbit
Release Notes
New Features
Tests
Documentation