From 215210e5825f9c9e3c595a262825b862f7bac4d9 Mon Sep 17 00:00:00 2001 From: ankitkumar Date: Mon, 22 Jun 2026 13:16:05 +0530 Subject: [PATCH] feat: ack-channel corroborated by rework + semantic one-off gate; switch to SHA versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes. 1. Catch additively-phrased corrections (the "use UI testing as well" miss). The v1.3.3 fix made the assistant-ack channel require user doubt to corroborate. That suppressed the dominant self-ack false positive, but also dropped genuine corrections phrased as calm additive instructions ("use UI testing as well") that carry no doubt words — Claude acknowledged them and did real rework, yet they scored as a procedure, not a rule. Channel 2 is now corroborated by EITHER user doubt OR substantial corrective rework (an edit, or a turn clearing the procedure bar): ack_is_correction = correction_ack and (user_doubt or strong_rework) The no-rework self-ack ("I was wrong about my own claim" in a neutral chat) stays suppressed, because it has no rework to corroborate it. 2. Semantic one-off gate. Detection is deterministic and cannot tell a generalizable rule from a one-off (the UI-testing and ScopeCall corrections are structurally identical — same ack, same rework, opposite desired outcome). That call is delegated to the compounded-author RULE MODE gate. build_rule_reason now instructs Claude to judge generalizability FIRST and drop one-offs SILENTLY (no AskUserQuestion), surfacing the approval gate only when the lesson genuinely generalizes. So widening the trigger does not bring back one-off nagging — the LLM absorbs it. 3. Switch to commit-SHA versioning. Removed the "version" field from plugin.json so every push auto-propagates to installs (no manual version bump, no stale-cache problem where same-version installs keep old code). - score_turn: compute score before the correction decision; add strong_rework arm - build_rule_reason: generalize-or-drop-silently instruction - docstring updated; 2 new regression tests (ack+rework+no-doubt -> rule), 61/61 green Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/plugin.json | 1 - scripts/auto_propose.py | 55 +++++++++++++++++++++++++------------- tests/test_auto_propose.py | 46 ++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 94684d9..5a8da15 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "compounded", - "version": "1.3.3", "description": "Turns your agent into a trainable employee. Learns rules from your corrections (with your approval), and skills earn autonomy through demonstrated reliability \u2014 proposed \u2192 verified \u2192 trusted \u2192 autonomous. No daemon. No cloud.", "author": { "name": "Ankit Kumar", diff --git a/scripts/auto_propose.py b/scripts/auto_propose.py index 5ea6d36..ad9c3d1 100644 --- a/scripts/auto_propose.py +++ b/scripts/auto_propose.py @@ -39,10 +39,18 @@ - Channel 2 (assistant acknowledgment) is a CONFIRMING signal only. The model's ack vocabulary ("you're right", "my mistake", "i was wrong") also covers ordinary agreement and self-correction in meta-discussion, so an ack - counts as a correction only when the user's message also shows doubt - (a question, or a soft-doubt term). This kills the dominant false positive: - Claude saying "I was wrong" about its own prior claim while the user gave a - neutral instruction. See _user_shows_doubt. + counts as a correction only when corroborated by EITHER user doubt + (a question or a soft-doubt term, see _user_shows_doubt) OR substantial + corrective rework (an edit, or a turn heavy enough to clear the procedure + bar). Doubt catches questioned facts; rework catches corrections phrased as + calm additive instructions ("use UI testing as well") that carry no doubt + words. The no-rework self-ack ("I was wrong about my own claim" in a neutral + chat) stays suppressed — that is the dominant false positive. + +Generalizable-vs-one-off is NOT decided here. A correction that clears the bar +is handed to the compounded-author RULE MODE gate (build_rule_reason), which is +told to judge generalizability first and drop one-offs silently. Detection is +deterministic and intentionally cannot make that semantic call. Two capture kinds: procedure — a successful multi-step task worth saving as a replayable @@ -490,11 +498,6 @@ def score_turn(turn_events: list[dict], prior_user: dict | None) -> dict: correction_user = _has_correction_signal(prior_user) correction_ack = _assistant_acknowledged_correction(turn_events) user_doubt = _user_shows_doubt(prior_user) - # Channel 1 (user phrasing) arms a correction alone. Channel 2 (assistant - # ack) only counts when corroborated by user doubt — otherwise it fires on - # Claude's conversational politeness and self-corrections. - ack_is_correction = correction_ack and user_doubt - correction = correction_user or ack_is_correction score = 0 if len(tool_uses) >= THRESHOLD_TOOL_USES: @@ -508,6 +511,20 @@ def score_turn(turn_events: list[dict], prior_user: dict | None) -> dict: if plan_used: score += 1 + # Channel 1 (user phrasing) arms a correction alone. Channel 2 (assistant + # ack) needs corroboration, because ack vocabulary also covers politeness + # and Claude self-correcting its own prior claim. It is corroborated by + # EITHER user doubt OR substantial corrective rework (an edit, or a turn + # heavy enough to clear the procedure bar). The rework arm catches + # corrections phrased as calm additive instructions ("use UI testing as + # well") that carry no doubt words; the no-rework self-ack false positive + # stays suppressed. Whether the lesson generalizes vs. is a one-off is NOT + # decided here — that semantic call is delegated to the compounded-author + # RULE MODE gate (see build_rule_reason), which drops one-offs silently. + strong_rework = len(edit_files) >= 1 or score >= SCORE_TO_FIRE + ack_is_correction = correction_ack and (user_doubt or strong_rework) + correction = correction_user or ack_is_correction + # A correction followed by real corrective work is the highest-signal # learning event: capture the lesson as a rule. Otherwise a high-signal # clean turn captures as a procedure (the original behavior). @@ -583,15 +600,17 @@ def build_rule_reason(signals: dict) -> str: forced continuation is justified here (and stop_hook_active guards loops). """ return ( - f"[compounded] Correction detected — the user corrected your previous " - f"approach and you then did {signals['tool_uses']} tool call(s) of corrective work. " - f"This may encode a reusable behavioral rule (the delta between what the user " - f"asked, what you did, and how they corrected you). " - f"Invoke the `compounded-author` skill in RULE MODE: extract the generalizable " - f"lesson, ask the user to approve it via AskUserQuestion BEFORE saving, and if " - f"approved propose it as a `kind: rule` skill. " - f"If the correction was a one-off (specific value, path, or taste call with no " - f"general trigger), do nothing further and end your turn." + f"[compounded] Possible correction — the user steered your approach and you then " + f"did {signals['tool_uses']} tool call(s) of corrective work. " + f"This MAY encode a reusable behavioral rule, or it may be a one-off. " + f"Detection is deterministic and cannot tell those apart; that judgment is yours. " + f"FIRST, silently decide whether the lesson generalizes: would it apply to future, " + f"unrelated tasks (a durable working preference, a process the user wants by default)? " + f"Or is it specific to this code/value/path/taste call with no general trigger? " + f"If it is a one-off, do NOTHING — end your turn silently, do not ask the user. " + f"Only if it genuinely generalizes: invoke the `compounded-author` skill in RULE MODE, " + f"extract the lesson, ask the user to approve it via AskUserQuestion BEFORE saving, and " + f"if approved propose it as a `kind: rule` skill." ) diff --git a/tests/test_auto_propose.py b/tests/test_auto_propose.py index ed76f59..55d4fbf 100644 --- a/tests/test_auto_propose.py +++ b/tests/test_auto_propose.py @@ -251,6 +251,49 @@ def test_ack_with_soft_doubt_no_question_mark_fires(self) -> None: self.assertTrue(signals["user_doubt"]) # via "i think" / "wrong" self.assertEqual(signals["capture_kind"], "rule") + def test_additive_correction_with_rework_and_ack_captures_rule(self) -> None: + # Regression (field): the user corrected an approach with a calm, + # ADDITIVE instruction ("use UI testing as well") — no question, no + # doubt keyword — and Claude acknowledged and did real rework. Doubt + # is absent, but the rework arm corroborates the ack. Must arm rule + # capture (the generalize-vs-one-off call is the LLM gate's job). + events = [ + _user("use UI testing as well, don't sign off on DOM checks alone"), + _assistant( + tool_uses=[ + ("Edit", {"file_path": "/chart.ts", "old_string": "x", "new_string": "y"}), + ("Edit", {"file_path": "/feed.ts", "old_string": "x", "new_string": "y"}), + ("Bash", {"command": "npm test"}), + ], + text="You're right — I should have verified the rendered UI. Fixed and screenshotted.", + ), + _tool_result(), + ] + turn, prior = self.auto_propose._split_into_turns(events) + signals = self.auto_propose.score_turn(turn, prior) + self.assertTrue(signals["correction_ack"]) + self.assertFalse(signals["user_doubt"]) # no question / doubt word + self.assertEqual(signals["capture_kind"], "rule") + + def test_ack_with_rework_but_user_correction_phrased_additively(self) -> None: + # Companion to the suppression test: identical ack, but here real edits + # exist, so the rework arm flips it from procedure to rule. + events = [ + _user("also always run the linter before you finish"), + _assistant( + tool_uses=[ + ("Edit", {"file_path": "/a.py", "old_string": "x", "new_string": "y"}), + ("Bash", {"command": "ruff check"}), + ], + text="Good catch — running it now.", + ), + _tool_result(), + ] + turn, prior = self.auto_propose._split_into_turns(events) + signals = self.auto_propose.score_turn(turn, prior) + self.assertFalse(signals["user_doubt"]) + self.assertEqual(signals["capture_kind"], "rule") + def test_pattern_buried_in_user_paste_is_not_a_correction(self) -> None: # Regression (field): the user pasted a transcript that contained # "Is there a newer [model / SDK / tool] than [X]?" ~1000 chars in — @@ -500,9 +543,10 @@ def test_correction_session_blocks_with_rule_reason(self) -> None: self.assertEqual(result["rc"], 0) self.assertEqual(result["output"].get("decision"), "block") reason = result["output"]["reason"] - self.assertIn("Correction detected", reason) + self.assertIn("correction", reason.lower()) self.assertIn("RULE MODE", reason) self.assertIn("AskUserQuestion", reason) # approval gate is part of the nudge + self.assertIn("one-off", reason) # semantic gate: drop one-offs silently def test_rule_block_suppressed_when_stop_hook_active(self) -> None: # Loop guard: when this Stop fires after a previous block, never