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
2 changes: 1 addition & 1 deletion .github/workflows/release-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .claude-plugin/plugin.json .codex-plugin/plugin.json hooks/stop-hook.mjs hooks/ask-user-question-hook.mjs scripts/predict-interview-answer.mjs README.md
git add .claude-plugin/plugin.json .codex-plugin/plugin.json README.md
git commit -m "chore: release ${TAG}"
git tag -a "$TAG" -m "clone ${VERSION}"
git push origin HEAD:main "$TAG"
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ To update later: `claude plugin marketplace update clone-labs && claude plugin u

| Command | What it does |
|---|---|
| `/clone:interview "<topic>" [options]` | Clarify requirements into a local spec. |
| `/clone:interview "<topic>" [options]` | Clarify a goal into an executable plan. |
| `/clone:loop "<task>" [options]` | Start a loop. |
| `/clone:cancel-loop` | Cancel the active loop. |
| `/clone:api-key status\|import-env\|set\|clear` | Manage your Clone API key. |
Expand All @@ -123,11 +123,16 @@ To update later: `claude plugin marketplace update clone-labs && claude plugin u
interview answers. Default `0.75`.
- `--no-auto-answer` — disable Clone-predicted answers and always ask you.

Clone Interview is the requirements side of Clone. It inspects repo facts,
asks human-judgment questions one at a time, asks Clone MCP to predict how you
would answer, and only auto-records the answer when confidence clears the
threshold. Low-confidence questions escalate to you. v1 is plugin-only for
question generation: Clone does not generate the interview questions yet.
Clone Interview is the goal-to-plan side of Clone. It inspects repo facts,
asks the highest-impact unresolved question one at a time, asks Clone MCP to
predict how you would answer, and only auto-records the answer when confidence
clears the threshold. Low-confidence questions escalate to you.

The working spec evolves through a Goal Contract, Decision Ledger, Plan Draft,
Readiness Audit, and Execution Handoff. The interview ends only when the goal,
scope, decision boundaries, acceptance criteria, tests/checks, and plan risks
are clear enough to choose a handoff path. v1 is plugin-only for question
generation: Clone does not generate the interview questions yet.

### Options for `/clone:loop`

Expand Down
15 changes: 5 additions & 10 deletions commands/cancel-loop.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
---
description: "Cancel active Clone Loop"
allowed-tools: ["Bash(test -f .claude/clone-loop.local.md:*)", "Bash(rm .claude/clone-loop.local.md)", "Read(.claude/clone-loop.local.md)"]
allowed-tools: Bash(node *cancel-clone-loop.mjs*)
hide-from-slash-command-tool: "true"
---

# Cancel Clone Loop

To cancel the active Clone Loop:
Use the Bash tool to execute the Node cancel script:

1. Check if `.claude/clone-loop.local.md` exists using Bash: `test -f .claude/clone-loop.local.md && echo "EXISTS" || echo "NOT_FOUND"`

2. **If NOT_FOUND**: Say "No active Clone Loop found."

3. **If EXISTS**:
- Read `.claude/clone-loop.local.md` to get the current iteration number from the `iteration:` field
- Remove the file using Bash: `rm .claude/clone-loop.local.md`
- Report: "Cancelled Clone Loop (was at iteration N)" where N is the iteration value
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/cancel-clone-loop.mjs"
```
18 changes: 16 additions & 2 deletions commands/interview.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: "Clarify requirements into a Clone Interview spec"
description: "Clarify a goal into a Clone Interview plan"
argument-hint: "TOPIC [--max-questions N] [--mode quick|deep] [--output PATH]"
allowed-tools: Bash(node *setup-clone-interview.mjs*), AskUserQuestion
hide-from-slash-command-tool: "true"
Expand All @@ -19,4 +19,18 @@ First inspect repository facts that are directly relevant to the topic. Auto-con

Ask all human-judgment questions through AskUserQuestion so Clone Interview can predict the user's answer first. When the prediction clears the configured threshold, the hook will fill in the answer automatically. When confidence is low, AskUserQuestion will reach the user normally.

Ask one question at a time. For free-form answers that include scope or constraints, structure the answer and confirm nothing was lost before recording it. Before closing, restate the one-sentence goal and get user confirmation, then update the spec markdown.
Drive the interview toward an executable plan, not just a list of answers. Keep the spec's Goal Contract, Decision Ledger, Plan Draft, Readiness Audit, and Execution Handoff current after every answer.

Ask one question at a time using this frame:

```md
Current understanding: ...
Blocked decision: ...
Clone predicted answer: ... or escalated
Question: ...
Plan impact: ...
```

Question priority is goal, outcome, scope/non-goals, decision boundaries, constraints, acceptance criteria, then plan risks. Record decisions as `[from-user]`, high-confidence Clone answers as `[from-clone][auto]`, and low-confidence suggestions as `[from-clone][escalated]` before asking the user.

Before closing, run the Readiness Audit. If anything fails, ask the single question that most improves the Plan Draft. When the audit passes, ask the user to choose: Refine plan, Start Clone Loop with this plan, Implement manually from this plan, or Stop here.
188 changes: 12 additions & 176 deletions hooks/ask-user-question-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { loopHistoryPath, loopStatePath } from '../scripts/clone-paths.mjs'
import { resolveCloneToken } from '../scripts/clone-auth.mjs'
import {
HISTORY_WINDOW_TURNS,
Expand All @@ -16,85 +16,25 @@ import {
appendLoopHistory,
validateActiveLoopState,
} from '../scripts/loop-state-guard.mjs'
import { cloneMcpClientInfo, cloneMcpEndpoint, cloneMcpRpc } from '../scripts/clone-mcp-client.mjs'
import { numeric, parseJson, readStdin } from '../scripts/clone-utils.mjs'
import { mapPredictionToOption, rankedPredictionCandidates } from '../scripts/interview-answer-utils.mjs'

const LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md')
const LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl')
const CLIENT_VERSION = '0.14.6'
const LOOP_STATE_FILE = loopStatePath()
const LOOP_HISTORY_FILE = loopHistoryPath()

function appendHistory(record) {
appendLoopHistory(LOOP_HISTORY_FILE, record)
}

function readStdin() {
return new Promise((resolveRead) => {
let input = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
input += chunk
})
process.stdin.on('end', () => resolveRead(input))
})
}

function parseJson(input) {
const normalized = input.replace(/^\uFEFF/, '').trim()
return normalized ? JSON.parse(normalized) : {}
}

function parseSse(text) {
const frames = text
.split(/\r?\n\r?\n/)
.map((event) =>
event
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice('data:'.length).trim())
.join('\n')
.trim(),
)
.filter(Boolean)

for (const frame of frames) {
try {
return JSON.parse(frame)
} catch {}
}

return JSON.parse(text)
}

async function rpc(endpoint, token, method, params = {}, sessionId = '') {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'X-Clone-API-Key': token,
}
if (sessionId) headers['mcp-session-id'] = sessionId

const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
const text = await res.text()
if (!res.ok) {
throw new Error(`Clone MCP ${method} failed with HTTP ${res.status}: ${text.slice(0, 500)}`)
}

return {
sessionId: res.headers.get('mcp-session-id') || sessionId,
payload: text ? parseSse(text) : null,
}
}

async function clonePredictNextPrompt({ agent, agentInput, threshold, sessionId }) {
const endpoint = process.env.CLONE_MCP_URL || 'https://api.clone.is/mcp'
const endpoint = cloneMcpEndpoint()
const { token } = resolveCloneToken()

const init = await rpc(endpoint, token, 'initialize', {
const init = await cloneMcpRpc(endpoint, token, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'clone-loop', version: CLIENT_VERSION },
clientInfo: cloneMcpClientInfo(),
})

const args = {
Expand All @@ -105,7 +45,7 @@ async function clonePredictNextPrompt({ agent, agentInput, threshold, sessionId
}
if (sessionId) args.session_id = sessionId

const prediction = await rpc(
const prediction = await cloneMcpRpc(
endpoint,
token,
'tools/call',
Expand All @@ -120,112 +60,8 @@ async function clonePredictNextPrompt({ agent, agentInput, threshold, sessionId
return JSON.parse(content.text)
}

function normalize(value) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[`*_~"'“”‘’]/g, '')
.replace(/\s+/g, ' ')
.trim()
}

function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function labelBoundaryMatch(text, label) {
const normalizedLabel = normalize(label)
if (!normalizedLabel) return false
return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(normalizedLabel)}([^\\p{L}\\p{N}]|$)`, 'u').test(text)
}

function mapPredictionToOption(predictedResponse, options) {
const labels = options.map((option) => String(option?.label || '').trim()).filter(Boolean)
if (!labels.length) return null

const text = normalize(predictedResponse)
const firstLine = normalize(String(predictedResponse || '').split(/\r?\n/).find((line) => line.trim()) || '')

const letterMatch = text.match(/^(?:option\s*)?([a-j])(?:[.)\s:-]|$)/)
if (letterMatch) {
const index = letterMatch[1].charCodeAt(0) - 'a'.charCodeAt(0)
if (labels[index]) return labels[index]
}

const exactMatches = labels.filter((label) => {
const normalizedLabel = normalize(label)
return normalizedLabel === text || normalizedLabel === firstLine
})
if (exactMatches.length === 1) return exactMatches[0]

const prefixMatches = labels.filter((label) => {
const normalizedLabel = normalize(label)
return (
text.startsWith(`${normalizedLabel} `) ||
text.startsWith(`${normalizedLabel}.`) ||
text.startsWith(`${normalizedLabel}:`) ||
text.startsWith(`${normalizedLabel}-`)
)
})
if (prefixMatches.length === 1) return prefixMatches[0]

const containedMatches = labels.filter((label) => labelBoundaryMatch(text, label))
return containedMatches.length === 1 ? containedMatches[0] : null
}

function numericConfidence(value, fallback = 0) {
const confidence = Number(value)
return Number.isFinite(confidence) ? confidence : fallback
}

function candidateText(candidate) {
if (!candidate) return ''
if (typeof candidate === 'string') return candidate
return String(
candidate.predicted_response ||
candidate.response ||
candidate.text ||
candidate.content ||
candidate.message ||
'',
)
}

function candidateConfidence(candidate, fallback) {
if (!candidate || typeof candidate === 'string') return fallback
return numericConfidence(
candidate.confidence ??
candidate.probability ??
candidate.prob ??
candidate.p ??
candidate.score,
fallback,
)
}

function rankedPredictionCandidates(prediction) {
const candidates = []
const topConfidence = numericConfidence(prediction?.confidence, 0)

if (prediction?.predicted_response) {
candidates.push({
text: String(prediction.predicted_response),
confidence: topConfidence,
index: candidates.length,
})
}

for (const candidate of Array.isArray(prediction?.candidates) ? prediction.candidates : []) {
const text = candidateText(candidate)
if (!text) continue
candidates.push({
text,
confidence: candidateConfidence(candidate, topConfidence),
index: candidates.length,
})
}

return candidates.sort((left, right) => right.confidence - left.confidence || left.index - right.index)
return numeric(value, fallback)
}

function chooseHighestConfidenceOption(prediction, options) {
Expand All @@ -234,7 +70,7 @@ function chooseHighestConfidenceOption(prediction, options) {
const mapped = []

for (const candidate of rankedCandidates) {
const answer = mapPredictionToOption(candidate.text, options)
const answer = mapPredictionToOption(candidate.text, options, null)
if (!answer) continue
mapped.push({ ...candidate, answer })
}
Expand Down
21 changes: 3 additions & 18 deletions hooks/interview-question-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,25 +1,10 @@
#!/usr/bin/env node

import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { loopStatePath } from '../scripts/clone-paths.mjs'
import { parseJson, readStdin } from '../scripts/clone-utils.mjs'
import { predictInterviewAnswer } from '../scripts/predict-interview-answer.mjs'

function readStdin() {
return new Promise((resolveRead) => {
let input = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
input += chunk
})
process.stdin.on('end', () => resolveRead(input))
})
}

function parseJson(input) {
const normalized = String(input || '').replace(/^\uFEFF/, '').trim()
return normalized ? JSON.parse(normalized) : {}
}

function allowAnswer({ toolInput, answers, confidence, threshold }) {
console.log(
JSON.stringify(
Expand Down Expand Up @@ -53,7 +38,7 @@ async function main() {
if (hookInput.tool_name && hookInput.tool_name !== 'AskUserQuestion') return

// Clone Loop owns AskUserQuestion while an active loop state exists.
if (existsSync(resolve(process.cwd(), '.claude', 'clone-loop.local.md'))) return
if (existsSync(loopStatePath())) return

const toolInput = hookInput.tool_input || {}
const questions = Array.isArray(toolInput.questions) ? toolInput.questions : []
Expand Down
Loading