Skip to content
Merged
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
1 change: 0 additions & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
55 changes: 37 additions & 18 deletions scripts/auto_propose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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).
Expand Down Expand Up @@ -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."
)


Expand Down
46 changes: 45 additions & 1 deletion tests/test_auto_propose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down
Loading