1414
1515from __future__ import annotations
1616
17+ import json
1718import re
1819from collections import Counter
1920from 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+
90191def 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
165293def append_entry (cfg : Config , entry_text : str ) -> Path :
0 commit comments