diff --git a/.gitignore b/.gitignore index fbe466e..5c0c18f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ # Per-session in-flight decision stubs (slice 083-07) — ephemeral, cleared after # each Stop-hook triage; never tracked. .jig/decision-scratch/ +# Suppression audit log (bug 011 option 4) — one line per silent drop in +# `dedup_scan_against_stubs`; local observability, per-checkout, never tracked. +.jig/decision-suppressions.log # Once-per-project servo-suggestion breadcrumb (slice 072-02) — local "jig # already suggested /servo:scaffold-init here" record; per-checkout, must NOT # travel across branches/clones (the explicit opt-out `.jig/no-servo-hint` is diff --git a/docs/bugs/011-decision-dedup-suppresses-reversals.md b/docs/bugs/011-decision-dedup-suppresses-reversals.md index 09dcad5..f4ac5aa 100644 --- a/docs/bugs/011-decision-dedup-suppresses-reversals.md +++ b/docs/bugs/011-decision-dedup-suppresses-reversals.md @@ -149,7 +149,13 @@ Suppression is removed outright. Containment survives as a *signal*, not a verdi whose tokens are largely contained by a recorded decision is **flagged** as a possible duplicate and still surfaced, and the nudge asks the owner to triage it. Nothing is dropped **against the recorded corpus**, so the defect class this record describes cannot recur on those paths — and -option 4 below is subsumed there, because no silent suppression remains to log. +option 4 below is subsumed there, because no silent suppression remains to log.[^opt4] + +[^opt4]: This last clause is an **overclaim**, preserved here as the record stood at close + (per [ADR-0010](../decisions/adr-0010-amendment-scope-records-vs-live-prose.md) — records keep + their original prose; the correction is annotated, not rewritten). `dedup_scan_against_stubs` + still drops silently against in-flight stubs (Residual 1), so one silent suppression path + outlives the fix and *was* worth logging. See [`## Amendments`](#amendments). This resolves the trade-off that made the remedy a decision. Re-surfacing was the cost of option 1; it is now uniform (every candidate re-surfaces every Stop, as scan hits already did — @@ -316,3 +322,20 @@ similarity to that record is evidence *for* surfacing, not against. ## Release log - 2026-07-16 - released claim from claude/bug-109-c7b049: diagnose-only; fix choice deferred to maintainer (see issue #109) + +## Amendments + +Additions after this record closed `DONE`, per [ADR-0010](../decisions/adr-0010-amendment-scope-records-vs-live-prose.md) +(closed records get amendments; live prose corrected inline). + +- **2026-07-17 — option 4 (suppression logging) implemented for the surviving drop path.** + This record's `## Fix class` claimed "no silent suppression remains to log," but Residual 1 + (`dedup_scan_against_stubs` still drops a Tier-3 agent reversal of an in-flight stub) is exactly + such a path. Per ADR-0010 that original prose is left intact and flagged with a footnote; the + correction lives here rather than rewriting the closed record. Option 4 is now delivered *for that + path only*: every drop in `dedup_scan_against_stubs` appends one JSON line to + `.jig/decision-suppressions.log` (dropped candidate, covering stub, containment score, call + site). **Additive observability only — the drop itself is unchanged**, and the residual stays a + residual: the fix class (whether that path should drop at all) remains the maintainer's call and + is not decided here. The bug stays `DONE`; this removes the silence, not the residual. See + issue [#109](https://github.com/ramboz/jig/issues/109) and `refinement-todo.md`. diff --git a/docs/refinement-todo.md b/docs/refinement-todo.md index 31fc2ed..45f6d92 100644 --- a/docs/refinement-todo.md +++ b/docs/refinement-todo.md @@ -254,4 +254,6 @@ Resolved by [spec 041](specs/041-routing-observability/spec.md) — routing is o ### Decision: bug 011 residuals — Tier-3 stub reversal + undismissable re-nag **Deferred:** [Bug 011](bugs/011-decision-dedup-suppresses-reversals.md) (from [issue 109](https://github.com/ramboz/jig/issues/109)) removed containment-based suppression on both *recorded-corpus* paths (`decision_scan.flag_duplicates`, `decision_scratch.flag_recorded_stubs`) but deliberately left `decision_scratch.dedup_scan_against_stubs` dropping on the same rule, because it collapses one decision captured both ways into a single line — duplication with no triage value. The independent bug-review pass surfaced two consequences, both accepted rather than fixed: **(1)** a **Tier-3 agent reversal of an in-flight stub** is still silently dropped there, since agent prose never produces a stub of its own to survive as (stub "Use Redis instead of Memcached for the cache" vs agent "going with Memcached for the cache instead" → 3/4 = 0.75). The Tier-2 user case is safe — a user reversal fires `UserPromptSubmit` and survives as its own stub. **(2)** recording a decision no longer quiets its nudge. Suppression *was* the acknowledgement channel: pruning a recorded stub is what made the Stop hook go silent. With nothing pruned, **there is no dismissal path at all** — the Stop hook fires every turn, not once per session, so every stub and every recorded-corpus scan hit re-surfaces for the rest of the session even after the owner does exactly what the nudge asks. Measured: a 4-decision session whose decisions were *all* recorded still injects ~350 tokens of `additionalContext` on every turn, indefinitely. Under [spec 055](specs/055-context-cost-discipline/spec.md) (cost ≈ orchestrator context × turns) that is a cost on the primary metric, not a cosmetic one. Two lesser consequences ride along: `clear_scratch` is unreachable in production, so a per-session `.jig/decision-scratch/.log` outlives its session on disk (bounded — append-only, 240-char clip, git-ignored), and `render_summary` does not collapse identical quotes, so a decision stated twice lists twice. **Resolution trigger:** (1) someone reports a *real* agent-stated reversal that never reached the owner — Tier 3 is low-confidence by construction, so the value of chasing it is unproven; (2) the re-nag is reported as noise, **or** a decision-heavy session is observed paying meaningful per-turn context for candidates the owner has already triaged. Watch the per-turn cost, *not* the scratch file's size — disk growth is bounded and will never trip on its own, so a disk-shaped trigger would park this forever. Also fires if `decision_scratch`'s lifecycle is next revised for another reason. +**Observability (landed 2026-07-17, bug 011 option 4):** consequence (1)'s silent drop is no longer invisible — every drop in `dedup_scan_against_stubs` now appends one JSON line to `.jig/decision-suppressions.log` (dropped candidate, covering stub, containment score, call site). Additive only; the drop is unchanged. This gives resolution trigger (1) — "a real agent-stated reversal that never reached the owner" — an on-disk record to check instead of leaving the case unfalsifiable. It removes the silence, **not** the residual: whether that path should drop at all stays deferred here. +**Log-noise follow-up (deferred, investigate later):** the log records *every* dedup drop, but only **agent-sourced** drops (Tier-3, whose prose never survives as its own stub) can hide a lost decision — user-sourced drops (Tier 1/2) are always recorded elsewhere, so logging them is noise that buries the one case worth reading. A tighter contract would log only agent-sourced drops, so every line means "a possibly-lost reversal, worth a look." Not done now: kept the fix purely additive with the simple "every drop is logged" contract. Revisit if the log is ever actually read and found noisy, or fold into the `flag-don't-drop` mitigation above — at which point the drop (and this log) goes away and the point is moot. **Mitigation idea:** For (1), give `dedup_scan_against_stubs` the same flag-don't-drop treatment and let `render_summary` collapse identical quotes at render time instead — the double-surface concern is a *rendering* problem, not a suppression one, which is arguably the cleaner boundary. For (2), the honest shape is an **explicit** acknowledgement channel to replace the implicit one suppression provided — e.g. the owner marks a candidate triaged (a `triaged` key on the stub / a dismissed-quotes list the hook consults), which is a real signal rather than containment pretending to be one. Cheaper interim options: surface a flagged candidate once per session rather than once per Stop, or have `render_summary` collapse identical quotes. Note that "clear the log once every stub is recorded" is circular — "recorded" is precisely the judgment containment could not make, and re-introducing it would re-open bug 011. diff --git a/hooks/scripts/jig-decision-capture.sh b/hooks/scripts/jig-decision-capture.sh index 8e4d5c7..3cf329b 100755 --- a/hooks/scripts/jig-decision-capture.sh +++ b/hooks/scripts/jig-decision-capture.sh @@ -71,7 +71,8 @@ try: # settled both ways surfaces ONCE (no double-surface). Stubs are already # flagged against recorded; flag the scan hits against recorded too. Nothing # is suppressed for being already recorded — the owner triages (bug 011). - scan_candidates = dedup_scan_against_stubs(scan(messages), stub_candidates) + scan_candidates = dedup_scan_against_stubs( + scan(messages), stub_candidates, project_dir=project_dir) candidates = stub_candidates + flag_duplicates(scan_candidates, recorded) candidates.sort(key=lambda c: (c.turn, c.tier)) diff --git a/hooks/scripts/lib/decision_scan.py b/hooks/scripts/lib/decision_scan.py index a15acd5..08db947 100644 --- a/hooks/scripts/lib/decision_scan.py +++ b/hooks/scripts/lib/decision_scan.py @@ -279,21 +279,32 @@ def token_sets(texts): return [t for t in (normalize_tokens(x) for x in (texts or [])) if t] -def is_contained(quote, others): - """True iff >= `_DUPLICATE_CONTAINMENT` of `quote`'s tokens appear in some set. +def containment(quote, others): + """Highest fraction of `quote`'s tokens contained in any set in `others`. - Below `_DUPLICATE_MIN_TOKENS` the answer is always False: a 1-2 token quote + Below `_DUPLICATE_MIN_TOKENS` the score is always 0.0: a 1-2 token quote clears any threshold against a set that happens to share those tokens. + Sole home of the containment *measure*: `is_contained` is a threshold over + it, and suppression logging reports the raw score it returns — so the number + that decides a drop and the number written to the log cannot drift apart. + """ + tokens = normalize_tokens(quote) + if len(tokens) < _DUPLICATE_MIN_TOKENS: + return 0.0 + return max((len(tokens & other) / len(tokens) + for other in (others or []) if other), + default=0.0) + + +def is_contained(quote, others): + """True iff >= `_DUPLICATE_CONTAINMENT` of `quote`'s tokens appear in some set. + Sole home of the containment rule: every caller — this module against recorded decisions, `decision_scratch` against recorded decisions and against in-flight stubs — comes through here, so the sites cannot drift apart. """ - tokens = normalize_tokens(quote) - if len(tokens) < _DUPLICATE_MIN_TOKENS: - return False - return any(len(tokens & other) / len(tokens) >= _DUPLICATE_CONTAINMENT - for other in (others or [])) + return containment(quote, others) >= _DUPLICATE_CONTAINMENT def flag_duplicates(candidates, recorded_texts): diff --git a/hooks/scripts/lib/decision_scratch.py b/hooks/scripts/lib/decision_scratch.py index d5c0b26..69d00a8 100644 --- a/hooks/scripts/lib/decision_scratch.py +++ b/hooks/scripts/lib/decision_scratch.py @@ -37,8 +37,10 @@ from decision_scan import ( Candidate, clip, + containment, is_contained, is_user_override, + normalize_tokens, strip_machine_text, token_sets, ) @@ -46,13 +48,16 @@ from lib.decision_scan import ( Candidate, clip, + containment, is_contained, is_user_override, + normalize_tokens, strip_machine_text, token_sets, ) _SCRATCH_DIR = Path(".jig") / "decision-scratch" +_SUPPRESSION_LOG = Path(".jig") / "decision-suppressions.log" _SOURCE_TIER = {"askuserquestion": 1, "user-override": 2} @@ -193,7 +198,58 @@ def stubs_to_candidates(stubs) -> list: return candidates -def dedup_scan_against_stubs(scan_candidates, stub_candidates) -> list: +def suppression_log_path(project_dir) -> Path: + """Path to the append-only suppression log under `.jig/`. + + One project-wide inspectable file (not per-session): a drop is rare and the + owner reads it as an audit trail across sessions. Append-only and unbounded + by design — it is never read back into context (so it has no context cost), + each line is a clipped one-liner, and it is git-ignored; the owner prunes it + if it ever grows inconvenient.""" + return Path(project_dir) / _SUPPRESSION_LOG + + +def log_suppression(project_dir, call_site, candidate, matched, score) -> bool: + """Append one JSON line recording a suppressed (dropped) candidate. + + The additive half of bug 011: the drop itself is unchanged, but it stops + being silent — `nothing found` and `found and dropped` now differ on disk. + Records the dropped candidate, the record/stub that covered it, and the raw + containment score that decided the drop, tagged with the call site so a + future second drop path is distinguishable. Fail-open (returns False on any + error): observability must never change what is or isn't suppressed. + """ + if not project_dir: + return False + try: + cand_entry = { + "quote": candidate.quote, + "tier": candidate.tier, + "who": candidate.who, + } + # The dropped candidate's transcript turn is the owner's locator for the + # possibly-reversing statement — the whole reason this log exists. Guard + # it the same way `matched`'s turn is (a stub with no turn carries -1). + if isinstance(candidate.turn, int) and candidate.turn >= 0: + cand_entry["turn"] = candidate.turn + entry = { + "timestamp": _now_iso(), + "call_site": call_site, + "candidate": cand_entry, + "matched": matched, + "containment": round(score, 3), + } + path = suppression_log_path(project_dir) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, sort_keys=True) + "\n") + return True + except Exception: + return False + + +def dedup_scan_against_stubs(scan_candidates, stub_candidates, + project_dir=None) -> list: """Drop scan candidates already captured in-flight (no double-surface). Deliberately still a *drop*, unlike the recorded-corpus paths: this collapses @@ -204,10 +260,45 @@ def dedup_scan_against_stubs(scan_candidates, stub_candidates) -> list: Residual (bug 011, accepted): a Tier-3 *agent* restatement that in fact reverses a stub is dropped here, since agent prose never produces a stub of its own to survive as. Low value — Tier 3 is low-confidence by construction. + + `project_dir` opts the drop into the `.jig/` suppression log (bug 011 option + 4): every drop is recorded there so it is no longer indistinguishable from an + empty scan. Omitting it (pure-function callers) logs nothing and changes + nothing. """ - stubs = token_sets(c.quote for c in (stub_candidates or [])) - return [c for c in (scan_candidates or []) - if not is_contained(c.quote, stubs)] + stubs = list(stub_candidates or []) + stub_sets = token_sets(c.quote for c in stubs) + kept = [] + for cand in (scan_candidates or []): + if is_contained(cand.quote, stub_sets): + if project_dir is not None: + _log_stub_suppression(project_dir, cand, stubs) + continue + kept.append(cand) + return kept + + +def _log_stub_suppression(project_dir, cand, stubs) -> None: + """Record one drop from `dedup_scan_against_stubs`, naming the covering stub. + + Fail-open wrapper: finding the best-matching stub for the log must never + raise into the drop loop.""" + try: + best = None + best_score = 0.0 + for stub in stubs: + score = containment(cand.quote, [normalize_tokens(stub.quote)]) + if score >= best_score: + best, best_score = stub, score + matched = None + if best is not None: + matched = {"quote": best.quote, "who": best.who, "tier": best.tier} + if isinstance(best.turn, int) and best.turn >= 0: + matched["turn"] = best.turn + log_suppression(project_dir, "dedup_scan_against_stubs", + cand, matched, best_score) + except Exception: + pass def _collect_strings(obj, out, budget=40): diff --git a/hooks/scripts/lib/test_decision_scratch.py b/hooks/scripts/lib/test_decision_scratch.py index 6e39496..33b8c9f 100644 --- a/hooks/scripts/lib/test_decision_scratch.py +++ b/hooks/scripts/lib/test_decision_scratch.py @@ -7,6 +7,7 @@ python3 hooks/scripts/lib/test_decision_scratch.py """ +import json import sys import tempfile import unittest @@ -192,6 +193,110 @@ def test_none_scan_candidates_is_fail_open(self): self.assertEqual(ds.dedup_scan_against_stubs(None, []), []) +class SuppressionLogTests(unittest.TestCase): + """`dedup_scan_against_stubs` is the one path #117 left that still *drops* + silently (bug 011). Every drop must leave one inspectable line under `.jig/` + so 'nothing was found' and 'found and dropped' stop being the same on disk. + Additive only: the drop itself is unchanged.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.project = self._tmp.name + + def tearDown(self): + self._tmp.cleanup() + + def _scan(self, quote, tier=1): + return Candidate(tier=tier, who="user", quote=quote, turn=2, confidence="high") + + def _log_lines(self): + path = ds.suppression_log_path(self.project) + if not path.exists(): + return [] + return [json.loads(ln) for ln in path.read_text().splitlines() if ln.strip()] + + def test_a_drop_writes_one_inspectable_line(self): + stub_cands = ds.stubs_to_candidates([ + {"quote": "Use Redis instead of Memcached for the cache", + "who": "user", "source": "user-override", "turn": 1}]) + scan_cands = [self._scan("Use Redis instead of Memcached for the cache")] + + kept = ds.dedup_scan_against_stubs( + scan_cands, stub_cands, project_dir=self.project) + + # Behaviour unchanged: still dropped. + self.assertEqual(kept, []) + # ...but no longer silent. + lines = self._log_lines() + self.assertEqual(len(lines), 1) + entry = lines[0] + self.assertEqual(entry["call_site"], "dedup_scan_against_stubs") + self.assertEqual(entry["candidate"]["quote"], + "Use Redis instead of Memcached for the cache") + self.assertEqual(entry["candidate"]["tier"], 1) + # The dropped candidate's transcript turn is logged as the owner's + # locator for the possibly-reversing statement. + self.assertEqual(entry["candidate"]["turn"], 2) + self.assertIn("Redis", entry["matched"]["quote"]) + self.assertGreaterEqual(entry["containment"], 0.6) + + def test_candidate_without_a_turn_omits_the_key(self): + # A candidate carrying the no-turn sentinel (-1) logs no `turn` key, + # rather than a misleading negative locator. + stub_cands = ds.stubs_to_candidates([ + {"quote": "Use Redis instead of Memcached for the cache", + "who": "user", "source": "user-override"}]) + scan_cands = [Candidate(tier=3, who="agent", + quote="Use Redis instead of Memcached for the cache", + turn=-1, confidence="low")] + + ds.dedup_scan_against_stubs( + scan_cands, stub_cands, project_dir=self.project) + + self.assertNotIn("turn", self._log_lines()[0]["candidate"]) + + def test_a_kept_candidate_logs_nothing(self): + stub_cands = ds.stubs_to_candidates([ + {"quote": "Use Redis for caching", "who": "user", + "source": "user-override"}]) + scan_cands = [self._scan("Adopt a hexagonal architecture boundary")] + + kept = ds.dedup_scan_against_stubs( + scan_cands, stub_cands, project_dir=self.project) + + self.assertEqual(len(kept), 1) + self.assertEqual(self._log_lines(), []) + + def test_logging_is_opt_in_no_project_dir_writes_nothing(self): + # Pure-function callers (unit tests, non-hook use) pass no project_dir + # and must neither log nor change behaviour. + stub_cands = ds.stubs_to_candidates([ + {"quote": "Use Redis instead of Memcached for the cache", + "who": "user", "source": "user-override"}]) + scan_cands = [self._scan("Use Redis instead of Memcached for the cache")] + + kept = ds.dedup_scan_against_stubs(scan_cands, stub_cands) + + self.assertEqual(kept, []) + self.assertFalse(ds.suppression_log_path(self.project).exists()) + + def test_logging_failure_never_breaks_the_drop(self): + # Fail-open like every decision hook: a broken log sink must not change + # what is suppressed. Point the project at a file so the `.jig/` dir + # cannot be created. + blocker = Path(self.project) / "not-a-dir" + blocker.write_text("x") + stub_cands = ds.stubs_to_candidates([ + {"quote": "Use Redis instead of Memcached for the cache", + "who": "user", "source": "user-override"}]) + scan_cands = [self._scan("Use Redis instead of Memcached for the cache")] + + kept = ds.dedup_scan_against_stubs( + scan_cands, stub_cands, project_dir=str(blocker)) + + self.assertEqual(kept, []) + + class AnswerExtractionTests(unittest.TestCase): def test_extracts_from_nested_response(self): resp = {"answers": [{"label": "Pair 05+06", "value": "together"}]} diff --git a/hooks/scripts/test_jig_decision_capture.py b/hooks/scripts/test_jig_decision_capture.py index 0b53d54..e49923f 100644 --- a/hooks/scripts/test_jig_decision_capture.py +++ b/hooks/scripts/test_jig_decision_capture.py @@ -173,6 +173,29 @@ def test_recorded_stub_is_flagged_not_pruned(self): self.assertTrue(ds.scratch_path(self.project, "sess-1").exists(), "the stub must persist — the owner has not triaged it yet") + def test_dedup_drop_writes_a_suppression_log_line(self): + # Bug 011 option 4, end-to-end: the one path that still *drops* silently + # (a scan hit collapsed into its in-flight stub) must leave one + # inspectable line under .jig/ — 'nothing found' and 'found and dropped' + # must differ on disk. The decision still surfaces once via the stub. + ds.append_stub(self.project, "sess-1", "user", + "English should not be the default language", "user-override") + run_hook(self.project, self._payload( + [{"role": "user", "content": "English should not be the default language."}])) + log = ds.suppression_log_path(self.project) + self.assertTrue(log.exists(), "a silent drop must be recorded") + lines = [json.loads(ln) for ln in log.read_text().splitlines() if ln.strip()] + self.assertEqual(len(lines), 1) + self.assertEqual(lines[0]["call_site"], "dedup_scan_against_stubs") + self.assertIn("default language", lines[0]["candidate"]["quote"]) + + def test_no_suppression_log_when_nothing_dropped(self): + # The distinguishing half: a plain surfacing with no drop writes no log, + # so a present line always means a real suppression. + run_hook(self.project, self._payload( + [{"role": "user", "content": "Use a banner instead of a modal."}])) + self.assertFalse(ds.suppression_log_path(self.project).exists()) + def test_malformed_json_never_crashes(self): result = run_hook(self.project, None, raw="{not valid json") self.assertEqual(result.returncode, 0, msg="stderr: %s" % result.stderr) diff --git a/hosts/claude/hooks/scripts/jig-decision-capture.sh b/hosts/claude/hooks/scripts/jig-decision-capture.sh index 8e4d5c7..3cf329b 100644 --- a/hosts/claude/hooks/scripts/jig-decision-capture.sh +++ b/hosts/claude/hooks/scripts/jig-decision-capture.sh @@ -71,7 +71,8 @@ try: # settled both ways surfaces ONCE (no double-surface). Stubs are already # flagged against recorded; flag the scan hits against recorded too. Nothing # is suppressed for being already recorded — the owner triages (bug 011). - scan_candidates = dedup_scan_against_stubs(scan(messages), stub_candidates) + scan_candidates = dedup_scan_against_stubs( + scan(messages), stub_candidates, project_dir=project_dir) candidates = stub_candidates + flag_duplicates(scan_candidates, recorded) candidates.sort(key=lambda c: (c.turn, c.tier)) diff --git a/hosts/claude/hooks/scripts/lib/decision_scan.py b/hosts/claude/hooks/scripts/lib/decision_scan.py index a15acd5..08db947 100644 --- a/hosts/claude/hooks/scripts/lib/decision_scan.py +++ b/hosts/claude/hooks/scripts/lib/decision_scan.py @@ -279,21 +279,32 @@ def token_sets(texts): return [t for t in (normalize_tokens(x) for x in (texts or [])) if t] -def is_contained(quote, others): - """True iff >= `_DUPLICATE_CONTAINMENT` of `quote`'s tokens appear in some set. +def containment(quote, others): + """Highest fraction of `quote`'s tokens contained in any set in `others`. - Below `_DUPLICATE_MIN_TOKENS` the answer is always False: a 1-2 token quote + Below `_DUPLICATE_MIN_TOKENS` the score is always 0.0: a 1-2 token quote clears any threshold against a set that happens to share those tokens. + Sole home of the containment *measure*: `is_contained` is a threshold over + it, and suppression logging reports the raw score it returns — so the number + that decides a drop and the number written to the log cannot drift apart. + """ + tokens = normalize_tokens(quote) + if len(tokens) < _DUPLICATE_MIN_TOKENS: + return 0.0 + return max((len(tokens & other) / len(tokens) + for other in (others or []) if other), + default=0.0) + + +def is_contained(quote, others): + """True iff >= `_DUPLICATE_CONTAINMENT` of `quote`'s tokens appear in some set. + Sole home of the containment rule: every caller — this module against recorded decisions, `decision_scratch` against recorded decisions and against in-flight stubs — comes through here, so the sites cannot drift apart. """ - tokens = normalize_tokens(quote) - if len(tokens) < _DUPLICATE_MIN_TOKENS: - return False - return any(len(tokens & other) / len(tokens) >= _DUPLICATE_CONTAINMENT - for other in (others or [])) + return containment(quote, others) >= _DUPLICATE_CONTAINMENT def flag_duplicates(candidates, recorded_texts): diff --git a/hosts/claude/hooks/scripts/lib/decision_scratch.py b/hosts/claude/hooks/scripts/lib/decision_scratch.py index d5c0b26..69d00a8 100644 --- a/hosts/claude/hooks/scripts/lib/decision_scratch.py +++ b/hosts/claude/hooks/scripts/lib/decision_scratch.py @@ -37,8 +37,10 @@ from decision_scan import ( Candidate, clip, + containment, is_contained, is_user_override, + normalize_tokens, strip_machine_text, token_sets, ) @@ -46,13 +48,16 @@ from lib.decision_scan import ( Candidate, clip, + containment, is_contained, is_user_override, + normalize_tokens, strip_machine_text, token_sets, ) _SCRATCH_DIR = Path(".jig") / "decision-scratch" +_SUPPRESSION_LOG = Path(".jig") / "decision-suppressions.log" _SOURCE_TIER = {"askuserquestion": 1, "user-override": 2} @@ -193,7 +198,58 @@ def stubs_to_candidates(stubs) -> list: return candidates -def dedup_scan_against_stubs(scan_candidates, stub_candidates) -> list: +def suppression_log_path(project_dir) -> Path: + """Path to the append-only suppression log under `.jig/`. + + One project-wide inspectable file (not per-session): a drop is rare and the + owner reads it as an audit trail across sessions. Append-only and unbounded + by design — it is never read back into context (so it has no context cost), + each line is a clipped one-liner, and it is git-ignored; the owner prunes it + if it ever grows inconvenient.""" + return Path(project_dir) / _SUPPRESSION_LOG + + +def log_suppression(project_dir, call_site, candidate, matched, score) -> bool: + """Append one JSON line recording a suppressed (dropped) candidate. + + The additive half of bug 011: the drop itself is unchanged, but it stops + being silent — `nothing found` and `found and dropped` now differ on disk. + Records the dropped candidate, the record/stub that covered it, and the raw + containment score that decided the drop, tagged with the call site so a + future second drop path is distinguishable. Fail-open (returns False on any + error): observability must never change what is or isn't suppressed. + """ + if not project_dir: + return False + try: + cand_entry = { + "quote": candidate.quote, + "tier": candidate.tier, + "who": candidate.who, + } + # The dropped candidate's transcript turn is the owner's locator for the + # possibly-reversing statement — the whole reason this log exists. Guard + # it the same way `matched`'s turn is (a stub with no turn carries -1). + if isinstance(candidate.turn, int) and candidate.turn >= 0: + cand_entry["turn"] = candidate.turn + entry = { + "timestamp": _now_iso(), + "call_site": call_site, + "candidate": cand_entry, + "matched": matched, + "containment": round(score, 3), + } + path = suppression_log_path(project_dir) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, sort_keys=True) + "\n") + return True + except Exception: + return False + + +def dedup_scan_against_stubs(scan_candidates, stub_candidates, + project_dir=None) -> list: """Drop scan candidates already captured in-flight (no double-surface). Deliberately still a *drop*, unlike the recorded-corpus paths: this collapses @@ -204,10 +260,45 @@ def dedup_scan_against_stubs(scan_candidates, stub_candidates) -> list: Residual (bug 011, accepted): a Tier-3 *agent* restatement that in fact reverses a stub is dropped here, since agent prose never produces a stub of its own to survive as. Low value — Tier 3 is low-confidence by construction. + + `project_dir` opts the drop into the `.jig/` suppression log (bug 011 option + 4): every drop is recorded there so it is no longer indistinguishable from an + empty scan. Omitting it (pure-function callers) logs nothing and changes + nothing. """ - stubs = token_sets(c.quote for c in (stub_candidates or [])) - return [c for c in (scan_candidates or []) - if not is_contained(c.quote, stubs)] + stubs = list(stub_candidates or []) + stub_sets = token_sets(c.quote for c in stubs) + kept = [] + for cand in (scan_candidates or []): + if is_contained(cand.quote, stub_sets): + if project_dir is not None: + _log_stub_suppression(project_dir, cand, stubs) + continue + kept.append(cand) + return kept + + +def _log_stub_suppression(project_dir, cand, stubs) -> None: + """Record one drop from `dedup_scan_against_stubs`, naming the covering stub. + + Fail-open wrapper: finding the best-matching stub for the log must never + raise into the drop loop.""" + try: + best = None + best_score = 0.0 + for stub in stubs: + score = containment(cand.quote, [normalize_tokens(stub.quote)]) + if score >= best_score: + best, best_score = stub, score + matched = None + if best is not None: + matched = {"quote": best.quote, "who": best.who, "tier": best.tier} + if isinstance(best.turn, int) and best.turn >= 0: + matched["turn"] = best.turn + log_suppression(project_dir, "dedup_scan_against_stubs", + cand, matched, best_score) + except Exception: + pass def _collect_strings(obj, out, budget=40): diff --git a/hosts/codex/plugins/jig/hooks/scripts/jig-decision-capture.sh b/hosts/codex/plugins/jig/hooks/scripts/jig-decision-capture.sh index c0162bb..d132033 100644 --- a/hosts/codex/plugins/jig/hooks/scripts/jig-decision-capture.sh +++ b/hosts/codex/plugins/jig/hooks/scripts/jig-decision-capture.sh @@ -71,7 +71,8 @@ try: # settled both ways surfaces ONCE (no double-surface). Stubs are already # flagged against recorded; flag the scan hits against recorded too. Nothing # is suppressed for being already recorded — the owner triages (bug 011). - scan_candidates = dedup_scan_against_stubs(scan(messages), stub_candidates) + scan_candidates = dedup_scan_against_stubs( + scan(messages), stub_candidates, project_dir=project_dir) candidates = stub_candidates + flag_duplicates(scan_candidates, recorded) candidates.sort(key=lambda c: (c.turn, c.tier)) diff --git a/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scan.py b/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scan.py index a15acd5..08db947 100644 --- a/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scan.py +++ b/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scan.py @@ -279,21 +279,32 @@ def token_sets(texts): return [t for t in (normalize_tokens(x) for x in (texts or [])) if t] -def is_contained(quote, others): - """True iff >= `_DUPLICATE_CONTAINMENT` of `quote`'s tokens appear in some set. +def containment(quote, others): + """Highest fraction of `quote`'s tokens contained in any set in `others`. - Below `_DUPLICATE_MIN_TOKENS` the answer is always False: a 1-2 token quote + Below `_DUPLICATE_MIN_TOKENS` the score is always 0.0: a 1-2 token quote clears any threshold against a set that happens to share those tokens. + Sole home of the containment *measure*: `is_contained` is a threshold over + it, and suppression logging reports the raw score it returns — so the number + that decides a drop and the number written to the log cannot drift apart. + """ + tokens = normalize_tokens(quote) + if len(tokens) < _DUPLICATE_MIN_TOKENS: + return 0.0 + return max((len(tokens & other) / len(tokens) + for other in (others or []) if other), + default=0.0) + + +def is_contained(quote, others): + """True iff >= `_DUPLICATE_CONTAINMENT` of `quote`'s tokens appear in some set. + Sole home of the containment rule: every caller — this module against recorded decisions, `decision_scratch` against recorded decisions and against in-flight stubs — comes through here, so the sites cannot drift apart. """ - tokens = normalize_tokens(quote) - if len(tokens) < _DUPLICATE_MIN_TOKENS: - return False - return any(len(tokens & other) / len(tokens) >= _DUPLICATE_CONTAINMENT - for other in (others or [])) + return containment(quote, others) >= _DUPLICATE_CONTAINMENT def flag_duplicates(candidates, recorded_texts): diff --git a/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scratch.py b/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scratch.py index d5c0b26..69d00a8 100644 --- a/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scratch.py +++ b/hosts/codex/plugins/jig/hooks/scripts/lib/decision_scratch.py @@ -37,8 +37,10 @@ from decision_scan import ( Candidate, clip, + containment, is_contained, is_user_override, + normalize_tokens, strip_machine_text, token_sets, ) @@ -46,13 +48,16 @@ from lib.decision_scan import ( Candidate, clip, + containment, is_contained, is_user_override, + normalize_tokens, strip_machine_text, token_sets, ) _SCRATCH_DIR = Path(".jig") / "decision-scratch" +_SUPPRESSION_LOG = Path(".jig") / "decision-suppressions.log" _SOURCE_TIER = {"askuserquestion": 1, "user-override": 2} @@ -193,7 +198,58 @@ def stubs_to_candidates(stubs) -> list: return candidates -def dedup_scan_against_stubs(scan_candidates, stub_candidates) -> list: +def suppression_log_path(project_dir) -> Path: + """Path to the append-only suppression log under `.jig/`. + + One project-wide inspectable file (not per-session): a drop is rare and the + owner reads it as an audit trail across sessions. Append-only and unbounded + by design — it is never read back into context (so it has no context cost), + each line is a clipped one-liner, and it is git-ignored; the owner prunes it + if it ever grows inconvenient.""" + return Path(project_dir) / _SUPPRESSION_LOG + + +def log_suppression(project_dir, call_site, candidate, matched, score) -> bool: + """Append one JSON line recording a suppressed (dropped) candidate. + + The additive half of bug 011: the drop itself is unchanged, but it stops + being silent — `nothing found` and `found and dropped` now differ on disk. + Records the dropped candidate, the record/stub that covered it, and the raw + containment score that decided the drop, tagged with the call site so a + future second drop path is distinguishable. Fail-open (returns False on any + error): observability must never change what is or isn't suppressed. + """ + if not project_dir: + return False + try: + cand_entry = { + "quote": candidate.quote, + "tier": candidate.tier, + "who": candidate.who, + } + # The dropped candidate's transcript turn is the owner's locator for the + # possibly-reversing statement — the whole reason this log exists. Guard + # it the same way `matched`'s turn is (a stub with no turn carries -1). + if isinstance(candidate.turn, int) and candidate.turn >= 0: + cand_entry["turn"] = candidate.turn + entry = { + "timestamp": _now_iso(), + "call_site": call_site, + "candidate": cand_entry, + "matched": matched, + "containment": round(score, 3), + } + path = suppression_log_path(project_dir) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, sort_keys=True) + "\n") + return True + except Exception: + return False + + +def dedup_scan_against_stubs(scan_candidates, stub_candidates, + project_dir=None) -> list: """Drop scan candidates already captured in-flight (no double-surface). Deliberately still a *drop*, unlike the recorded-corpus paths: this collapses @@ -204,10 +260,45 @@ def dedup_scan_against_stubs(scan_candidates, stub_candidates) -> list: Residual (bug 011, accepted): a Tier-3 *agent* restatement that in fact reverses a stub is dropped here, since agent prose never produces a stub of its own to survive as. Low value — Tier 3 is low-confidence by construction. + + `project_dir` opts the drop into the `.jig/` suppression log (bug 011 option + 4): every drop is recorded there so it is no longer indistinguishable from an + empty scan. Omitting it (pure-function callers) logs nothing and changes + nothing. """ - stubs = token_sets(c.quote for c in (stub_candidates or [])) - return [c for c in (scan_candidates or []) - if not is_contained(c.quote, stubs)] + stubs = list(stub_candidates or []) + stub_sets = token_sets(c.quote for c in stubs) + kept = [] + for cand in (scan_candidates or []): + if is_contained(cand.quote, stub_sets): + if project_dir is not None: + _log_stub_suppression(project_dir, cand, stubs) + continue + kept.append(cand) + return kept + + +def _log_stub_suppression(project_dir, cand, stubs) -> None: + """Record one drop from `dedup_scan_against_stubs`, naming the covering stub. + + Fail-open wrapper: finding the best-matching stub for the log must never + raise into the drop loop.""" + try: + best = None + best_score = 0.0 + for stub in stubs: + score = containment(cand.quote, [normalize_tokens(stub.quote)]) + if score >= best_score: + best, best_score = stub, score + matched = None + if best is not None: + matched = {"quote": best.quote, "who": best.who, "tier": best.tier} + if isinstance(best.turn, int) and best.turn >= 0: + matched["turn"] = best.turn + log_suppression(project_dir, "dedup_scan_against_stubs", + cand, matched, best_score) + except Exception: + pass def _collect_strings(obj, out, budget=40):