Skip to content

Commit 98b764e

Browse files
eduralphclaude
andcommitted
feat(act): process-delta ledger — make Act self-auditing (#149)
Act was write-only: it scaffolded act-log entries but never tracked whether a proposed delta was applied or whether the miss recurred. Add process/act-ledger.json: register_signals() tracks each recurring signal (open); `pdca act resolve "<sig>" --location <loc>` marks it applied; recurrences() flags an applied signal that reappears in a cycle frozen AFTER the applied date — surfaced as a loud 'Ineffective deltas' section in `act index` and the `act log` scaffold. Deterministic instrumentation; the human still authors the delta. Adds test_act_ledger.py (8 cases) + an `act resolve` subcommand + a docs/07 note. Suite 284 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0806b00 commit 98b764e

4 files changed

Lines changed: 305 additions & 23 deletions

File tree

docs/07-publish-and-act.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ Act deltas land in five places: the **spec template** (a brief field), the
126126
files** (`.claude/agents/*.md`), or the **orchestration** (driver/state). Each is
127127
a permanent improvement to the baseline every future cycle starts from.
128128

129+
**Closing the loop — the process-delta ledger.** A delta is only worth anything if it
130+
*works*. Act keeps a `process/act-ledger.json`: each recurring signal it surfaces is
131+
tracked `open`; once you land the fix you run `pdca act resolve "<signal>" --location
132+
<path:line>` to mark it **applied**; and on a later review Act flags any applied delta
133+
whose miss **recurs** in a cycle frozen after the applied date — a loud "⚠ Ineffective
134+
deltas" section in `act index` / the `act log` scaffold. So Act audits its own
135+
prescriptions instead of writing them and forgetting.
136+
129137
---
130138

131139
## The loop, closed

template/src/pdca_harness/act.py

Lines changed: 149 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import json
1718
import re
1819
from collections import Counter
1920
from dataclasses import dataclass, field
@@ -87,6 +88,106 @@ def act_due(cfg: Config) -> bool:
8788
return cycles_since_review(cfg) >= cfg.act_cadence
8889

8990

91+
# ----------------------------------------------------------------------------
92+
# Process-delta ledger (issue #149): make Act self-auditing. A recurring signal is
93+
# REGISTERED (open); the human marks it APPLIED once a delta lands; a later Act flags it
94+
# when the same signal RECURS after the applied date — a likely-ineffective delta. Stored
95+
# as process/act-ledger.json: deterministic instrumentation; the human still authors the
96+
# delta and runs `pdca act resolve`.
97+
# ----------------------------------------------------------------------------
98+
_LEDGER = "act-ledger.json"
99+
100+
101+
def _ledger_path(cfg: Config) -> Path:
102+
"""Where the process-delta ledger lives (``process/act-ledger.json``)."""
103+
return cfg.process_dir / _LEDGER
104+
105+
106+
def load_ledger(cfg: Config) -> list[dict]:
107+
"""The process-delta ledger, or ``[]`` if absent/unreadable."""
108+
p = _ledger_path(cfg)
109+
if not p.exists():
110+
return []
111+
try:
112+
data = json.loads(p.read_text(encoding="utf-8"))
113+
return data if isinstance(data, list) else []
114+
except (ValueError, OSError):
115+
return []
116+
117+
118+
def _save_ledger(cfg: Config, entries: list[dict]) -> None:
119+
cfg.process_dir.mkdir(parents=True, exist_ok=True)
120+
_ledger_path(cfg).write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8")
121+
122+
123+
def _recurring(entries: list[ActEntry]) -> dict[str, str]:
124+
"""Normalized-signal → a representative raw text, for each signal appearing in more
125+
than one cycle (the §10 Act-candidate + §6 NEEDS-HUMAN pool). A miss is "the same"
126+
across cycles by its normalized key, so a class showing once in §10 of one cycle and
127+
once in §6 of another still counts as recurring."""
128+
counts: Counter = Counter()
129+
raw_of: dict[str, str] = {}
130+
for e in entries:
131+
for s in e.act_candidates + e.needs_human:
132+
n = _norm(s)
133+
if not n:
134+
continue
135+
counts[n] += 1
136+
raw_of.setdefault(n, s)
137+
return {n: raw_of[n] for n, c in counts.items() if c > 1}
138+
139+
140+
def register_signals(cfg: Config, entries: list[ActEntry], date: str) -> list[str]:
141+
"""Track each recurring signal not already in the ledger as an ``open`` entry
142+
(idempotent, deduped by normalized signal). Returns the raw texts newly registered."""
143+
ledger = load_ledger(cfg)
144+
known = {e.get("signal") for e in ledger}
145+
added: list[str] = []
146+
for norm, raw in _recurring(entries).items():
147+
if norm not in known:
148+
ledger.append({"signal": norm, "raw": raw, "first_seen": date,
149+
"status": "open", "applied_date": None, "location": ""})
150+
added.append(raw)
151+
if added:
152+
_save_ledger(cfg, ledger)
153+
return added
154+
155+
156+
def resolve(cfg: Config, query: str, location: str, date: str) -> str | None:
157+
"""Mark the first ``open`` ledger entry matching ``query`` (case-insensitive substring
158+
of its raw text or normalized signal) ``applied`` on ``date`` with ``location``.
159+
Returns the matched raw text, or ``None`` if nothing matched."""
160+
ledger = load_ledger(cfg)
161+
q = query.strip().lower()
162+
for e in ledger:
163+
if e.get("status") == "open" and (
164+
q in e.get("raw", "").lower() or q in e.get("signal", "")):
165+
e.update(status="applied", applied_date=date, location=location)
166+
_save_ledger(cfg, ledger)
167+
return e.get("raw", "")
168+
return None
169+
170+
171+
def recurrences(cfg: Config, entries: list[ActEntry] | None = None) -> list[dict]:
172+
"""``applied`` ledger entries whose signal reappears in a cycle frozen AFTER the
173+
applied date — the delta did not stop the miss, so it is likely ineffective. Each:
174+
``{signal, applied, recurred_in: [ids]}``."""
175+
entries = index(cfg) if entries is None else entries
176+
out: list[dict] = []
177+
for led in load_ledger(cfg):
178+
if led.get("status") != "applied":
179+
continue
180+
applied = led.get("applied_date") or ""
181+
sig = led.get("signal", "")
182+
hits = [e.bundle.name.replace("issue_", "") for e in entries
183+
if e.date and (not applied or e.date > applied)
184+
and sig in {_norm(s) for s in (e.act_candidates + e.needs_human)}]
185+
if hits:
186+
out.append({"signal": led.get("raw", sig), "applied": applied,
187+
"recurred_in": hits})
188+
return out
189+
190+
90191
def index(cfg: Config, since: str | None = None) -> list[ActEntry]:
91192
"""Extract §6/§7/§9/§10 from each frozen bundle, newest filtering via §9 date."""
92193
entries = [_extract(d / "SUMMARY.md", d) for d in frozen_bundles(cfg)]
@@ -106,7 +207,8 @@ def patterns(entries: list[ActEntry]) -> dict[str, list[str]]:
106207

107208

108209
# ----------------------------------------------------------------------------
109-
def render_index(entries: list[ActEntry], pats: dict[str, list[str]]) -> str:
210+
def render_index(entries: list[ActEntry], pats: dict[str, list[str]],
211+
ledger: list[dict] | None = None, recs: list[dict] | None = None) -> str:
110212
lines = [f"# Act bundle index — {len(entries)} frozen cycle(s)", ""]
111213
if not entries:
112214
lines.append("(no frozen bundles — nothing to review yet)")
@@ -133,33 +235,59 @@ def render_index(entries: list[ActEntry], pats: dict[str, list[str]]) -> str:
133235
any_pat = True
134236
if not any_pat:
135237
lines.append("- (none yet)")
238+
# Process-delta ledger (#149): tracked signals + a loud flag for any applied delta
239+
# whose miss recurred (likely ineffective).
240+
if ledger is not None:
241+
lines += ["", "## Process-delta ledger"]
242+
if not ledger:
243+
lines.append("- (empty — no recurring signal tracked yet)")
244+
for e in ledger:
245+
tag = (f"applied {e.get('applied_date', '')}"
246+
if e.get("status") == "applied" else "open")
247+
loc = f" → {e['location']}" if e.get("location") else ""
248+
lines.append(f"- [{tag}] {e.get('raw', '')}{loc}")
249+
if recs:
250+
lines += ["", "## ⚠ Ineffective deltas (recurred after applied)"]
251+
for r in recs:
252+
lines.append(f"- {r['signal']} — applied {r['applied']}, recurred in "
253+
+ ", ".join(r["recurred_in"]))
136254
return "\n".join(lines) + "\n"
137255

138256

139-
def scaffold_entry(entries: list[ActEntry], pats: dict[str, list[str]], date: str) -> str:
140-
"""A dated act-log entry with bundles + patterns filled, deltas left to the human."""
257+
def scaffold_entry(entries: list[ActEntry], pats: dict[str, list[str]], date: str,
258+
recs: list[dict] | None = None) -> str:
259+
"""A dated act-log entry with bundles + patterns filled, deltas left to the human.
260+
261+
``recs`` (issue #149) are applied process-deltas whose miss recurred — surfaced as a
262+
loud section so the review revisits the ineffective delta, not just new signals."""
141263
ids = ", ".join(e.bundle.name.replace("issue_", "") for e in entries) or "—"
142264
exposed = [f"- [{label}] {it}" for label, items in pats.items() for it in items] or [
143265
"- (no recurring signal surfaced — note any single-cycle observation worth a delta)"
144266
]
145-
return "\n".join(
146-
[
147-
f"# Act review — {date} — cycles considered: {ids}",
148-
"",
149-
"## What the cycles' records exposed",
150-
*exposed,
151-
"",
152-
"## Process deltas (TODO — the human decides these; each must be located)",
153-
"- Spec template: <field added/clarified/removed> (path)",
154-
"- Ruleset: <rule added/retired/relaxed/tightened> (path:line)",
155-
"- Gates: <check added/promoted/moved> (path:line)",
156-
"- Agent skills: <SKILL.md / AGENTS.md adjustment> (path:line)",
157-
"",
158-
"## How effectiveness will be judged",
159-
"- The next Do phases should not recreate <specific issue>. Watch the next K cycles.",
160-
"",
161-
]
162-
)
267+
body = [
268+
f"# Act review — {date} — cycles considered: {ids}",
269+
"",
270+
"## What the cycles' records exposed",
271+
*exposed,
272+
]
273+
if recs:
274+
body += ["", "## ⚠ Ineffective deltas (recurred after applied)"]
275+
body += [f"- {r['signal']} (applied {r['applied']}) recurred in "
276+
f"{', '.join(r['recurred_in'])} — the delta may be ineffective; revisit it"
277+
for r in recs]
278+
body += [
279+
"",
280+
"## Process deltas (TODO — the human decides these; each must be located)",
281+
"- Spec template: <field added/clarified/removed> (path)",
282+
"- Ruleset: <rule added/retired/relaxed/tightened> (path:line)",
283+
"- Gates: <check added/promoted/moved> (path:line)",
284+
"- Agent skills: <SKILL.md / AGENTS.md adjustment> (path:line)",
285+
"",
286+
"## How effectiveness will be judged",
287+
"- The next Do phases should not recreate <specific issue>. Watch the next K cycles.",
288+
"",
289+
]
290+
return "\n".join(body)
163291

164292

165293
def append_entry(cfg: Config, entry_text: str) -> Path:

template/src/pdca_harness/cli.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ def main(argv: list[str] | None = None) -> int:
105105
p_actlog.add_argument("--since", help="only consider cycles signed off on/after this ISO date")
106106
p_actlog.add_argument("--date", required=True, help="review date (ISO; Act is out-of-band so pass it)")
107107
p_actlog.add_argument("--append", action="store_true", help="append to process/act-log.md (default: print)")
108+
p_actres = act_sub.add_parser("resolve",
109+
help="mark a tracked recurring signal as a delta you applied (#149)")
110+
p_actres.add_argument("signal", help="substring of the recurring signal to mark applied")
111+
p_actres.add_argument("--location", default="", help="where the delta landed (path:line / rule)")
112+
p_actres.add_argument("--date", help="applied date (ISO; default today)")
108113

109114
p_signoff = sub.add_parser("signoff", help="record the human Check sign-off (§9)")
110115
p_signoff.add_argument("issue_id")
@@ -441,13 +446,16 @@ def _act(cfg: Config, args: argparse.Namespace) -> int:
441446
return _act_index(cfg, args)
442447
if args.act_cmd == "log":
443448
return _act_log(cfg, args)
449+
if args.act_cmd == "resolve":
450+
return _act_resolve(cfg, args)
444451
return 2
445452

446453

447454
def _act_index(cfg: Config, args: argparse.Namespace) -> int:
448455
"""Print the read-only Act bundle index across frozen cycles."""
449456
entries = act.index(cfg, since=args.since)
450-
print(act.render_index(entries, act.patterns(entries)))
457+
print(act.render_index(entries, act.patterns(entries),
458+
act.load_ledger(cfg), act.recurrences(cfg)))
451459
return 0
452460

453461

@@ -462,7 +470,9 @@ def _act_log(cfg: Config, args: argparse.Namespace) -> int:
462470
if not entries:
463471
print("no frozen cycles to review (need COMPLETE bundles)", file=sys.stderr)
464472
return 1
465-
text = act.scaffold_entry(entries, act.patterns(entries), date=args.date)
473+
act.register_signals(cfg, entries, args.date) # track recurring signals (#149)
474+
text = act.scaffold_entry(entries, act.patterns(entries), date=args.date,
475+
recs=act.recurrences(cfg))
466476
if args.append:
467477
log = act.append_entry(cfg, text)
468478
act.mark_reviewed(cfg) # a manual Act review resets the flow cadence too (#109)
@@ -472,6 +482,18 @@ def _act_log(cfg: Config, args: argparse.Namespace) -> int:
472482
return 0
473483

474484

485+
def _act_resolve(cfg: Config, args: argparse.Namespace) -> int:
486+
"""Mark a tracked recurring signal as a process-delta the human applied (#149)."""
487+
date = args.date or datetime.date.today().isoformat()
488+
raw = act.resolve(cfg, args.signal, args.location, date)
489+
if raw is None:
490+
print(f"act resolve: no open ledger signal matching '{args.signal}' — run "
491+
f"`pdca act log` to register recurring signals first", file=sys.stderr)
492+
return 1
493+
print(f"marked applied ({date}): {raw}")
494+
return 0
495+
496+
475497
def _signoff(cfg: Config, args: argparse.Namespace) -> int:
476498
d = cfg.bundle(args.issue_id)
477499
summary = d / "SUMMARY.md"

0 commit comments

Comments
 (0)