Skip to content

Commit 9c2b13a

Browse files
eduralphclaude
andcommitted
feat(flow): cadence-gate the auto-Act after a flow (#109)
`pdca flow` auto-ran the Act leaf after every COMPLETE — including single- and few-item flows. But Act is a cross-cycle cadence beat: it reviews FROZEN cycles across the index for recurring signals and only yields a real delta once enough cycles have accumulated. Auto-running it after one slice spends an interactive leaf on insufficient signal and conflates the per-contribution loop with the cross-cycle cadence. Gate the auto-run on a configurable cadence that PERSISTS across flow invocations: run Act only when `[driver].act_cadence` (default 5) cycles have frozen SINCE the last Act. The "since last Act" count is derived from a durable marker (the frozen-bundle count at the last review, in process/.act-reviewed) — so five separate one-bundle flows trip it on the fifth, and it works even when a command-mode Act writes no act-log entry (the review still happened). Below the threshold the flow skips with a hint; `pdca act log` runs Act on demand (and resets the cadence); `--no-act` still forces skip; cadence 1 restores run-after-every-flow. - config: [driver].act_cadence (floor 1). - act: mark_reviewed / cycles_since_review / act_due (frozen is monotonic, so current-minus-marker is the unreviewed count). - leaves.run_act resets the marker whenever Act runs (both modes); cli `act log --append` resets it too. - flow: _maybe_run_act gates both the single and batch auto-run. Tests: ActCadence — Act held below cadence then fires on the 3rd flow (persistence across invocations), and the marker resets after a run. Existing flow tests set act_cadence=1 to keep asserting Act-after-flow. Full suite: 171 OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Eduard Ralph <15236434+eduralph@users.noreply.github.com>
1 parent 3ccd9a6 commit 9c2b13a

7 files changed

Lines changed: 123 additions & 8 deletions

File tree

template/pdca.toml.jinja

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ lanes = 1
3535
# `$PDCA_WORKTREE` too (see the gate examples below). On by default; best-effort (a target
3636
# that isn't a worktree-capable git checkout falls back to in-place). Set false to disable.
3737
worktree = true
38+
# Act cadence (issue #109). Act is a cross-cycle beat that yields a real delta only once
39+
# enough cycles have frozen to show a pattern, so `pdca flow` auto-runs it only when this
40+
# many cycles have frozen SINCE the last Act review — counted across flow invocations
41+
# (five one-bundle flows trip it on the fifth), not per-run. Below the threshold the flow
42+
# skips Act with a hint; `pdca act log` runs it on demand and `--no-act` always skips.
43+
# Set to 1 to run Act after every completed flow (the prior behaviour).
44+
act_cadence = 5
3845
# Close-disposition fast path (issue #60). A bundle whose brief's `Disposition hint`
3946
# matches one of these close / no-fix classes skips the builder + reviewer model leaves
4047
# (the engine's only token spend) and routes straight to sign-off, where the human

template/src/pdca_harness/act.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,45 @@ def frozen_bundles(cfg: Config) -> list[Path]:
4848
)
4949

5050

51+
# ----------------------------------------------------------------------------
52+
# Cadence (issue #109): Act yields a real delta only once enough cycles have frozen to
53+
# show a pattern. The flow auto-runs it only when this many cycles have frozen SINCE the
54+
# last Act — counted from a durable marker (the frozen count at the last review) so it
55+
# holds across flow invocations, and works even when a command-mode Act writes no
56+
# act-log entry (the model judged "no delta"). Frozen bundles are monotonic (COMPLETE is
57+
# terminal), so current-minus-marker is the count of unreviewed cycles.
58+
# ----------------------------------------------------------------------------
59+
_CADENCE_MARKER = ".act-reviewed" # holds the frozen-bundle count at the last Act
60+
61+
62+
def mark_reviewed(cfg: Config) -> None:
63+
"""Record that Act just ran: stamp the current frozen-bundle count (issue #109)."""
64+
cfg.process_dir.mkdir(parents=True, exist_ok=True)
65+
(cfg.process_dir / _CADENCE_MARKER).write_text(
66+
f"{len(frozen_bundles(cfg))}\n", encoding="utf-8")
67+
68+
69+
def cycles_since_review(cfg: Config) -> int:
70+
"""How many cycles have frozen since the last Act (issue #109).
71+
72+
``current frozen count − marker`` (no marker ⇒ all frozen cycles count). Never
73+
negative, so a deleted bundle can't wedge the cadence.
74+
"""
75+
marker = cfg.process_dir / _CADENCE_MARKER
76+
last = 0
77+
if marker.exists():
78+
try:
79+
last = int(marker.read_text(encoding="utf-8").strip() or 0)
80+
except ValueError:
81+
last = 0
82+
return max(0, len(frozen_bundles(cfg)) - last)
83+
84+
85+
def act_due(cfg: Config) -> bool:
86+
"""True iff enough cycles have frozen since the last Act to warrant a review (#109)."""
87+
return cycles_since_review(cfg) >= cfg.act_cadence
88+
89+
5190
def index(cfg: Config, since: str | None = None) -> list[ActEntry]:
5291
"""Extract §6/§7/§9/§10 from each frozen bundle, newest filtering via §9 date."""
5392
entries = [_extract(d / "SUMMARY.md", d) for d in frozen_bundles(cfg)]

template/src/pdca_harness/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ def _act_log(cfg: Config, args: argparse.Namespace) -> int:
424424
text = act.scaffold_entry(entries, act.patterns(entries), date=args.date)
425425
if args.append:
426426
log = act.append_entry(cfg, text)
427+
act.mark_reviewed(cfg) # a manual Act review resets the flow cadence too (#109)
427428
print(f"appended entry to {log}")
428429
else:
429430
print(text)

template/src/pdca_harness/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ class Config:
122122
# checkout directly, as before). Best-effort: a target that isn't a worktree-capable
123123
# git checkout silently falls back to in-place.
124124
worktree: bool = True
125+
# Act cadence (issue #109): Act is a cross-cycle beat that only yields a real delta
126+
# once enough cycles have frozen to show a pattern, so ``flow`` auto-runs it only when
127+
# this many cycles have frozen SINCE the last Act review (counted across flow
128+
# invocations, not per-run). Below it, the flow skips Act with a hint. ``1`` restores
129+
# run-after-every-flow; ``--no-act`` always forces skip. ``[driver].act_cadence``.
130+
act_cadence: int = 5
125131
# Close-disposition fast path (issue #60): the disposition-hint classes that mark a
126132
# bundle as close / no-fix, so the driver skips the builder + reviewer leaves and
127133
# routes it straight to sign-off. ``[driver].close_dispositions`` in pdca.toml; the
@@ -207,6 +213,7 @@ def leaf(name: str) -> LeafConfig:
207213
lanes = int(os.environ["PDCA_LANES"])
208214
lanes = max(1, lanes)
209215
worktree = bool(driver_cfg.get("worktree", True)) # issue #94; on by default
216+
act_cadence = max(1, int(driver_cfg.get("act_cadence", 5))) # issue #109
210217

211218
# Close-disposition classes (issue #60): a configured list retunes the default
212219
# for an instance's tracker vocabulary; absent ⇒ the built-in default.
@@ -244,6 +251,7 @@ def leaf(name: str) -> LeafConfig:
244251
gates_runner=gates_runner,
245252
lanes=lanes,
246253
worktree=worktree,
254+
act_cadence=act_cadence,
247255
close_dispositions=close_dispositions,
248256
)
249257

template/src/pdca_harness/flow.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import threading
2525
from pathlib import Path
2626

27-
from . import brief, driver, lane, leaves, merged, publish, queue, signoff, state
27+
from . import act, brief, driver, lane, leaves, merged, publish, queue, signoff, state
2828
from .config import Config
2929

3030

@@ -117,6 +117,27 @@ def _signoff_and_apply(
117117
return _apply_decision(cfg, d, by=by, today=today, apply_now=apply_now)
118118

119119

120+
def _maybe_run_act(cfg: Config, today: str, *, any_complete: bool) -> None:
121+
"""Run the Act beat after a flow only when it's *due* by cadence (issue #109).
122+
123+
Act is a cross-cycle beat that yields a real delta only once enough cycles have
124+
frozen to show a pattern, so auto-running it after every small flow spends an
125+
interactive leaf on insufficient signal. Run it only when ``act_cadence`` cycles have
126+
frozen SINCE the last Act (counted from a durable marker, so it holds across separate
127+
flow invocations — five one-bundle flows trip it on the fifth). Below the threshold,
128+
skip with a hint; ``--no-act`` (``do_act=False``) still forces skip upstream.
129+
"""
130+
if not any_complete:
131+
return
132+
if act.act_due(cfg):
133+
leaves.run_act(cfg, today)
134+
else:
135+
n = act.cycles_since_review(cfg)
136+
print(f"flow: Act skipped — {n} cycle(s) frozen since the last Act "
137+
f"(cadence {cfg.act_cadence}); run `pdca act log` when the backlog is "
138+
f"worth a review.", file=sys.stderr)
139+
140+
120141
def _plan_if_unplanned(cfg: Config, d: Path, csv: str | None) -> bool:
121142
"""If the bundle has no brief, run the (single) Plan leaf. Return True if planned."""
122143
if state.state(d) != state.UNPLANNED:
@@ -166,8 +187,8 @@ def flow(
166187
if rc:
167188
print(f"flow: issue_{issue_id} is COMPLETE but publish did not complete "
168189
f"(rc {rc}) — NOT published; run `pdca publish {issue_id}`.", file=sys.stderr)
169-
if do_act and final == state.COMPLETE:
170-
leaves.run_act(cfg, today)
190+
if do_act:
191+
_maybe_run_act(cfg, today, any_complete=(final == state.COMPLETE))
171192
return final
172193

173194

@@ -431,8 +452,9 @@ def _drive_and_act(
431452
print(f"flow: {d.name} is COMPLETE but publish did not complete "
432453
f"(rc {rc}) — NOT published; run `pdca publish "
433454
f"{d.name.removeprefix('issue_')}`.", file=sys.stderr)
434-
if do_act and any(s == state.COMPLETE for s in results.values()):
435-
leaves.run_act(cfg, today)
455+
if do_act:
456+
_maybe_run_act(cfg, today,
457+
any_complete=any(s == state.COMPLETE for s in results.values()))
436458
return results
437459

438460

template/src/pdca_harness/leaves.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,8 +697,11 @@ def signoff_rationale(d: Path) -> str:
697697
def run_act(cfg: Config, date: str) -> None:
698698
if cfg.act.mode == "command":
699699
_invoke(cfg.act, cfg.root, _act_prompt(cfg, date))
700-
return
701-
_stub_act(cfg, date)
700+
else:
701+
_stub_act(cfg, date)
702+
# Reset the cadence marker (issue #109) whenever the Act beat runs — even if a
703+
# command-mode Act judged "no delta" and wrote no act-log entry, the review happened.
704+
act_mod.mark_reviewed(cfg)
702705

703706

704707
def _act_prompt(cfg: Config, date: str) -> str:

template/tests/test_flow_slice.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from types import SimpleNamespace
2020
from unittest import mock
2121

22-
from pdca_harness import brief, cli, driver, flow, leaves, queue, signoff, state
22+
from pdca_harness import act, brief, cli, driver, flow, leaves, queue, signoff, state
2323
from pdca_harness.config import Config, LeafConfig
2424

2525
TEMPLATES = Path(__file__).resolve().parents[1] / "templates"
@@ -45,6 +45,7 @@ def _stub_config(root: Path) -> Config:
4545
signoff=LeafConfig(mode="stub", family="claude", interactive=True),
4646
publisher=LeafConfig(mode="stub", family="claude", interactive=True),
4747
act=LeafConfig(mode="stub", family="claude", interactive=True),
48+
act_cadence=1, # most flow tests assert Act runs after a flow; cadence #109 tested separately
4849
)
4950

5051

@@ -1017,5 +1018,39 @@ def test_authored_brief_reads_planned(self) -> None:
10171018
self.assertEqual(state.state(d), state.PLANNED)
10181019

10191020

1021+
class ActCadence(unittest.TestCase):
1022+
"""flow auto-runs Act only when act_cadence cycles have frozen since the last Act —
1023+
counted across flow invocations, not per-run (issue #109)."""
1024+
1025+
def setUp(self) -> None:
1026+
self.tmp = Path(tempfile.mkdtemp())
1027+
self.cfg = _stub_config(self.tmp)
1028+
self.cfg.act_cadence = 3
1029+
1030+
def tearDown(self) -> None:
1031+
shutil.rmtree(self.tmp, ignore_errors=True)
1032+
1033+
def _log(self) -> Path:
1034+
return self.cfg.process_dir / "act-log.md"
1035+
1036+
def test_act_held_below_cadence_then_fires_across_flows(self) -> None:
1037+
# Three separate single-bundle flows: Act must NOT run until the 3rd freezes the
1038+
# third cycle (cadence 3), proving the gate persists across invocations.
1039+
for i in (1, 2):
1040+
flow.flow(self.cfg, f"AC{i}", do_act=True, today="2026-06-04")
1041+
self.assertFalse(self._log().exists(), f"Act ran too early (after flow {i})")
1042+
flow.flow(self.cfg, "AC3", do_act=True, today="2026-06-04")
1043+
self.assertTrue(self._log().exists()) # the 3rd frozen cycle trips cadence
1044+
1045+
def test_act_resets_after_running(self) -> None:
1046+
for i in (1, 2, 3):
1047+
flow.flow(self.cfg, f"R{i}", do_act=True, today="2026-06-04")
1048+
self.assertTrue(self._log().exists())
1049+
self.assertEqual(act.cycles_since_review(self.cfg), 0) # marker reset to frozen count
1050+
before = self._log().read_text(encoding="utf-8")
1051+
flow.flow(self.cfg, "R4", do_act=True, today="2026-06-04") # only 1 since → below cadence
1052+
self.assertEqual(self._log().read_text(encoding="utf-8"), before) # no new Act entry
1053+
1054+
10201055
if __name__ == "__main__":
10211056
unittest.main()

0 commit comments

Comments
 (0)