Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion docs/bugs/011-decision-dedup-suppresses-reversals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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`.
2 changes: 2 additions & 0 deletions docs/refinement-todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sid>.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.
3 changes: 2 additions & 1 deletion hooks/scripts/jig-decision-capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
27 changes: 19 additions & 8 deletions hooks/scripts/lib/decision_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
99 changes: 95 additions & 4 deletions hooks/scripts/lib/decision_scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,27 @@
from decision_scan import (
Candidate,
clip,
containment,
is_contained,
is_user_override,
normalize_tokens,
strip_machine_text,
token_sets,
)
except ImportError: # pragma: no cover
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}


Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
Loading
Loading