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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,13 @@ To update later: `claude plugin marketplace update clone-labs && claude plugin u
2. When Claude tries to stop, the Stop hook intercepts.
3. The hook asks Clone MCP `predict_next_prompt` for what you'd most likely
say next.
4. **Above threshold** → prediction is injected and Claude continues.
**Below** → loop ends and asks for human input.
4. **Above threshold + Clone signals satisfaction** (`stop_recommended`) →
loop exits cleanly (Claude's Stop is allowed through). This is the
path that fires when Clone predicts a reply like "good. that's the
page." or "ship it" — your documented "we're done" voice.
**Above threshold, no satisfaction signal** → prediction is injected
and Claude continues.
**Below threshold** → loop ends and asks for human input.
5. Mid-loop `AskUserQuestion` popups are auto-answered the same way.

### What Clone actually sees
Expand Down
31 changes: 31 additions & 0 deletions hooks/stop-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,37 @@ The loop state file has been removed. Tell the user Clone could not produce a sa
return
}

// Satisfaction-shaped predictions trigger an early stop: Clone is
// saying the user would be done if they saw this output, so the
// loop should exit instead of force-continuing with the prediction
// as the next prompt. The server (`stop_recommended` on the
// PredictionCandidate) decides; we just act on the signal here.
// Gated on confidence so a low-confidence "ship it" doesn't slip
// through as a hallucination.
if (
prediction.stop_recommended === true &&
Number.isFinite(Number(predictedConfidence)) &&
Number(predictedConfidence) >= Number(cloneThreshold)
) {
appendHistory({
event: 'stop',
decision: 'satisfied',
iteration: nextIteration,
confidence: Number(predictedConfidence),
threshold: Number(cloneThreshold),
prediction_id: prediction.id || null,
status: prediction.status || null,
predicted_response: predictedResponse,
})
removeState()
console.error(
`Clone Loop: Clone predicted satisfaction ("${predictedResponse}", ` +
`confidence ${Number(predictedConfidence).toFixed(5)}). Exiting loop.`,
)
// Intentionally no block() — let Claude's stop go through naturally.
return
}

if (Number.isFinite(Number(predictedConfidence)) && Number(predictedConfidence) >= Number(cloneThreshold)) {
const predictedPromptSection = formatPredictedPromptSection({
iteration: nextIteration,
Expand Down
73 changes: 73 additions & 0 deletions tests/stop-hook-v2.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,79 @@ describe('Clone Loop v2 stop hook', () => {
)
})

it('exits the loop without blocking when Clone signals stop_recommended', async () => {
writeState(workdir)

await withMcpServer(
{
id: 'prediction-satisfied',
status: 'auto',
threshold: 0.6,
predicted_response: "good. that's the page.",
confidence: 0.82,
reasoning: "User's documented bar for this output is met.",
stop_recommended: true,
candidates: [],
k: 1,
model: 'test-model',
latency_ms: 9,
},
async (endpoint) => {
const result = await runHook(workdir, endpoint)

assert.equal(
result.status,
0,
JSON.stringify(
{ error: result.error?.message, signal: result.signal, stdout: result.stdout, stderr: result.stderr },
null,
2,
),
)
// Critical: stdout MUST be empty — no `decision: 'block'` JSON.
// That's how the hook signals "let Claude's Stop proceed".
assert.equal(result.stdout, '', `expected empty stdout, got: ${result.stdout}`)
// User-facing diagnostic on stderr.
assert.match(result.stderr, /Clone predicted satisfaction/)
// Loop state file removed so a new session doesn't resume.
assert.throws(() => readFileSync(join(workdir, '.claude', 'clone-loop.local.md')))
// History records the new 'satisfied' decision kind.
const history = readFileSync(join(workdir, '.claude', 'clone-loop.history.local.jsonl'), 'utf8')
assert.match(history, /"decision":"satisfied"/)
},
)
})

it('ignores stop_recommended when confidence is below threshold', async () => {
writeState(workdir)

await withMcpServer(
{
id: 'prediction-suspicious-satisfaction',
status: 'escalated',
threshold: 0.6,
predicted_response: 'ship it',
confidence: 0.42,
reasoning: 'Weak match — could be hallucinated satisfaction.',
stop_recommended: true, // satisfaction claim, but...
candidates: [],
k: 1,
model: 'test-model',
latency_ms: 8,
},
async (endpoint) => {
const result = await runHook(workdir, endpoint)

assert.equal(result.status, 0)
// ...confidence < threshold means we DON'T trust the stop signal.
// Fall through to the normal low-confidence escalation path.
const output = JSON.parse(result.stdout)
assert.equal(output.decision, 'block')
assert.match(output.reason, /not confident enough/i)
},
)
})

it('removes loop state and escalates when Clone confidence is low', async () => {
writeState(workdir)

Expand Down
Loading