diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml index 9a12517..7d038a0 100644 --- a/.github/workflows/release-plugin.yml +++ b/.github/workflows/release-plugin.yml @@ -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" diff --git a/README.md b/README.md index af1fb96..0fa6bb8 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ To update later: `claude plugin marketplace update clone-labs && claude plugin u | Command | What it does | |---|---| -| `/clone:interview "" [options]` | Clarify requirements into a local spec. | +| `/clone:interview "" [options]` | Clarify a goal into an executable plan. | | `/clone:loop "" [options]` | Start a loop. | | `/clone:cancel-loop` | Cancel the active loop. | | `/clone:api-key status\|import-env\|set\|clear` | Manage your Clone API key. | @@ -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` diff --git a/commands/cancel-loop.md b/commands/cancel-loop.md index 940275b..2b22568 100644 --- a/commands/cancel-loop.md +++ b/commands/cancel-loop.md @@ -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" +``` diff --git a/commands/interview.md b/commands/interview.md index fef03f7..4fff70a 100644 --- a/commands/interview.md +++ b/commands/interview.md @@ -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" @@ -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. diff --git a/hooks/ask-user-question-hook.mjs b/hooks/ask-user-question-hook.mjs index e36061b..4fcf272 100644 --- a/hooks/ask-user-question-hook.mjs +++ b/hooks/ask-user-question-hook.mjs @@ -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, @@ -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 = { @@ -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', @@ -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) { @@ -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 }) } diff --git a/hooks/interview-question-hook.mjs b/hooks/interview-question-hook.mjs index c81f252..eee89e4 100644 --- a/hooks/interview-question-hook.mjs +++ b/hooks/interview-question-hook.mjs @@ -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( @@ -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 : [] diff --git a/hooks/stop-hook.mjs b/hooks/stop-hook.mjs index c2687b6..2fa221a 100644 --- a/hooks/stop-hook.mjs +++ b/hooks/stop-hook.mjs @@ -2,6 +2,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' +import { formatPredictedPromptSection, purpleBold } from '../scripts/clone-display.mjs' +import { loopHistoryPath, loopStatePath } from '../scripts/clone-paths.mjs' import { resolveCloneToken } from '../scripts/clone-auth.mjs' import { HISTORY_WINDOW_TURNS, @@ -17,34 +19,16 @@ import { removeLoopState, validateActiveLoopState, } from '../scripts/loop-state-guard.mjs' +import { cloneMcpClientInfo, cloneMcpEndpoint, cloneMcpRpc } from '../scripts/clone-mcp-client.mjs' +import { isIntegerString, parseJson, readStdin } from '../scripts/clone-utils.mjs' -let LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md') -let LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl') -const CLIENT_VERSION = '0.14.6' -const ANSI_BOLD = '\u001b[1m' -const ANSI_PURPLE = '\u001b[35m' -const ANSI_RESET = '\u001b[0m' +let LOOP_STATE_FILE = loopStatePath() +let 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 removeState() { removeLoopState(LOOP_STATE_FILE) } @@ -53,96 +37,14 @@ function block(reason, systemMessage) { console.log(JSON.stringify({ decision: 'block', reason, systemMessage }, null, 2)) } -function formatPromptLines(value) { - return String(value || '') - .trim() - .split(/\r?\n/) - .map((line) => line.trim()) -} - -function purple(value) { - return `${ANSI_PURPLE}${value}${ANSI_RESET}` -} - -function purpleBold(value) { - return `${ANSI_BOLD}${ANSI_PURPLE}${value}${ANSI_RESET}` -} - -function formatIterationPromptLine({ iteration, prompt }) { - const [firstLine = '', ...remainingLines] = formatPromptLines(prompt) - const continuation = remainingLines.length - ? `\n${remainingLines.map((line) => purpleBold(`> ${line}`)).join('\n')}` - : '' - return `${purpleBold(`Iteration ${iteration} : ${firstLine}`)}${continuation}` -} - -function formatPredictedPromptSection({ iteration, predictedResponse, predictedConfidence, cloneThreshold, prediction }) { - const roundedConfidence = Number(predictedConfidence).toFixed(5) - return `${formatIterationPromptLine({ iteration, prompt: predictedResponse })} - -Confidence: ${roundedConfidence} / threshold: ${cloneThreshold} -Prediction status: ${prediction.status || ''} -Prediction id: ${prediction.id || ''}` -} - -function isIntegerString(value) { - return /^[0-9]+$/.test(String(value || '')) -} - -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 = { @@ -153,7 +55,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', @@ -203,8 +105,8 @@ async function main() { if (hookInput.stop_hook_active === true) return const root = hookInput.cwd ? resolve(String(hookInput.cwd)) : process.cwd() - LOOP_STATE_FILE = resolve(root, '.claude', 'clone-loop.local.md') - LOOP_HISTORY_FILE = resolve(root, '.claude', 'clone-loop.history.local.jsonl') + LOOP_STATE_FILE = loopStatePath(root) + LOOP_HISTORY_FILE = loopHistoryPath(root) const hookSession = hookInput.session_id ? String(hookInput.session_id) : '' const validation = validateActiveLoopState({ diff --git a/package.json b/package.json index edb1b03..0e8f7dd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/api-key-manager.test.mjs tests/setup-clone-loop.test.mjs tests/clone-interview-setup.test.mjs tests/clone-interview-predict.test.mjs tests/clone-interview-question-hook.test.mjs tests/clone-interview-plugin.test.mjs tests/post-tool-use-capture.test.mjs tests/repo-identity.test.mjs tests/codex-plugin.test.mjs tests/codex-setup.test.mjs tests/codex-post-tool-use.test.mjs tests/codex-stop-hook.test.mjs tests/stop-hook-v2.test.mjs tests/ask-user-question-hook.test.mjs tests/release-automation.test.mjs", + "test": "node --test tests/api-key-manager.test.mjs tests/setup-clone-loop.test.mjs tests/cancel-clone-loop.test.mjs tests/clone-interview-setup.test.mjs tests/clone-interview-predict.test.mjs tests/clone-interview-question-hook.test.mjs tests/clone-interview-plugin.test.mjs tests/post-tool-use-capture.test.mjs tests/repo-identity.test.mjs tests/codex-plugin.test.mjs tests/codex-setup.test.mjs tests/codex-post-tool-use.test.mjs tests/codex-stop-hook.test.mjs tests/stop-hook-v2.test.mjs tests/ask-user-question-hook.test.mjs tests/release-automation.test.mjs", "test:mcp:e2e": "node --test tests/remote-mcp-e2e.test.mjs" } } diff --git a/scripts/bump-plugin-version.mjs b/scripts/bump-plugin-version.mjs index 2d7f2fc..bf924be 100644 --- a/scripts/bump-plugin-version.mjs +++ b/scripts/bump-plugin-version.mjs @@ -63,11 +63,6 @@ function bumpVersion(version, part) { return `${major}.${minor}.${patch + 1}` } -function replaceOnce(contents, pattern, replacement, label) { - if (!pattern.test(contents)) throw new Error(`Could not find ${label}`) - return contents.replace(pattern, replacement) -} - function updateJsonVersion(path, nextVersion) { const manifest = JSON.parse(readFileSync(path, 'utf8')) const previousVersion = manifest.version @@ -76,19 +71,6 @@ function updateJsonVersion(path, nextVersion) { return previousVersion } -function updateClientVersion(path, nextVersion) { - const contents = readFileSync(path, 'utf8') - writeFileSync( - path, - replaceOnce( - contents, - /const CLIENT_VERSION = '\d+\.\d+\.\d+'/, - `const CLIENT_VERSION = '${nextVersion}'`, - `${path} CLIENT_VERSION`, - ), - ) -} - const options = parseArgs(process.argv.slice(2)) const manifestPath = join(options.root, '.claude-plugin', 'plugin.json') const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) @@ -104,8 +86,5 @@ const codexManifestPath = join(options.root, '.codex-plugin', 'plugin.json') if (existsSync(codexManifestPath)) { updateJsonVersion(codexManifestPath, nextVersion) } -updateClientVersion(join(options.root, 'hooks', 'stop-hook.mjs'), nextVersion) -updateClientVersion(join(options.root, 'hooks', 'ask-user-question-hook.mjs'), nextVersion) -updateClientVersion(join(options.root, 'scripts', 'predict-interview-answer.mjs'), nextVersion) console.log(`clone plugin version: ${previousVersion} -> ${nextVersion}`) diff --git a/scripts/cancel-clone-loop.mjs b/scripts/cancel-clone-loop.mjs new file mode 100644 index 0000000..4cf1b06 --- /dev/null +++ b/scripts/cancel-clone-loop.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from 'node:fs' +import { loopHistoryPath, loopStatePath } from './clone-paths.mjs' +import { appendLoopHistory, parseState, removeLoopState } from './loop-state-guard.mjs' + +const root = process.cwd() +const statePath = loopStatePath(root) +const historyPath = loopHistoryPath(root) + +if (!existsSync(statePath)) { + console.log('No active Clone Loop found.') + process.exit(0) +} + +let iteration = 'unknown' +try { + const state = parseState(readFileSync(statePath, 'utf8')) + iteration = state?.frontmatter?.iteration || iteration +} catch {} + +removeLoopState(statePath) +appendLoopHistory(historyPath, { + event: 'loop-cancel', + iteration, +}) + +console.log(`Cancelled Clone Loop (was at iteration ${iteration}).`) diff --git a/scripts/capture-tool-use.mjs b/scripts/capture-tool-use.mjs index 6ae0e44..6487226 100644 --- a/scripts/capture-tool-use.mjs +++ b/scripts/capture-tool-use.mjs @@ -2,31 +2,13 @@ import { appendLoopHistory, validateActiveLoopState } from './loop-state-guard.mjs' import { resolve } from 'node:path' +import { loopHistoryPath, loopStatePath } from './clone-paths.mjs' +import { parseJson, readStdin, stringValue } from './clone-utils.mjs' -let LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md') -let LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl') +let LOOP_STATE_FILE = loopStatePath() +let LOOP_HISTORY_FILE = loopHistoryPath() const CAP = 240 -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 stringValue(value) { - return String(value || '').trim() -} - function truncate(value) { const text = String(value || '').replace(/\s+/g, ' ').trim() if (text.length <= CAP) return text @@ -80,8 +62,8 @@ function appendHistory(record) { async function main() { const hookInput = parseJson(await readStdin()) const root = hookInput.cwd ? resolve(String(hookInput.cwd)) : process.cwd() - LOOP_STATE_FILE = resolve(root, '.claude', 'clone-loop.local.md') - LOOP_HISTORY_FILE = resolve(root, '.claude', 'clone-loop.history.local.jsonl') + LOOP_STATE_FILE = loopStatePath(root) + LOOP_HISTORY_FILE = loopHistoryPath(root) const hookSession = hookInput.session_id ? stringValue(hookInput.session_id) : '' const validation = validateActiveLoopState({ statePath: LOOP_STATE_FILE, diff --git a/scripts/clone-auth.mjs b/scripts/clone-auth.mjs index 47ee93b..a41b980 100644 --- a/scripts/clone-auth.mjs +++ b/scripts/clone-auth.mjs @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { nowIso } from './clone-utils.mjs' export const DEMO_TOKEN = 'clone_yc-reviewer-public-demo-2026' export const AUTH_FILE_NAME = 'auth.local.json' @@ -71,7 +72,7 @@ export function writePluginConfigToken(token, env = process.env) { `${JSON.stringify( { clone_api_token: value, - updated_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + updated_at: nowIso(), }, null, 2, diff --git a/scripts/clone-display.mjs b/scripts/clone-display.mjs new file mode 100644 index 0000000..61013ab --- /dev/null +++ b/scripts/clone-display.mjs @@ -0,0 +1,35 @@ +export const ANSI_BOLD = '\u001b[1m' +export const ANSI_PURPLE = '\u001b[35m' +export const ANSI_RESET = '\u001b[0m' + +export function purple(value) { + return `${ANSI_PURPLE}${value}${ANSI_RESET}` +} + +export function purpleBold(value) { + return `${ANSI_BOLD}${ANSI_PURPLE}${value}${ANSI_RESET}` +} + +export function formatPromptLines(value) { + return String(value || '') + .trim() + .split(/\r?\n/) + .map((line) => line.trim()) +} + +export function formatIterationPromptLine({ iteration, prompt }) { + const [firstLine = '', ...remainingLines] = formatPromptLines(prompt) + const continuation = remainingLines.length + ? `\n${remainingLines.map((line) => purpleBold(`> ${line}`)).join('\n')}` + : '' + return `${purpleBold(`Iteration ${iteration} : ${firstLine}`)}${continuation}` +} + +export function formatPredictedPromptSection({ iteration, predictedResponse, predictedConfidence, cloneThreshold, prediction }) { + const roundedConfidence = Number(predictedConfidence).toFixed(5) + return `${formatIterationPromptLine({ iteration, prompt: predictedResponse })} + +Confidence: ${roundedConfidence} / threshold: ${cloneThreshold} +Prediction status: ${prediction.status || ''} +Prediction id: ${prediction.id || ''}` +} diff --git a/scripts/clone-paths.mjs b/scripts/clone-paths.mjs new file mode 100644 index 0000000..984beda --- /dev/null +++ b/scripts/clone-paths.mjs @@ -0,0 +1,38 @@ +import { isAbsolute, relative, resolve } from 'node:path' + +export const CLAUDE_DIR = '.claude' +export const LOOP_STATE_FILE = 'clone-loop.local.md' +export const LOOP_HISTORY_FILE = 'clone-loop.history.local.jsonl' +export const INTERVIEW_STATE_FILE = 'clone-interview.local.md' +export const INTERVIEW_HISTORY_FILE = 'clone-interview.history.local.jsonl' +export const DEFAULT_INTERVIEW_OUTPUT_PATH = `${CLAUDE_DIR}/${INTERVIEW_STATE_FILE}` + +export function claudeDir(root = process.cwd()) { + return resolve(root, CLAUDE_DIR) +} + +export function loopStatePath(root = process.cwd()) { + return resolve(claudeDir(root), LOOP_STATE_FILE) +} + +export function loopHistoryPath(root = process.cwd()) { + return resolve(claudeDir(root), LOOP_HISTORY_FILE) +} + +export function interviewStatePath(root = process.cwd()) { + return resolve(claudeDir(root), INTERVIEW_STATE_FILE) +} + +export function interviewHistoryPath(root = process.cwd()) { + return resolve(claudeDir(root), INTERVIEW_HISTORY_FILE) +} + +export function projectLocalPath(root, path) { + const resolved = isAbsolute(path) ? resolve(path) : resolve(root, path) + const display = relative(root, resolved) || '.' + return { + display, + isInsideProject: !(display.startsWith('..') || isAbsolute(display)), + resolved, + } +} diff --git a/scripts/clone-utils.mjs b/scripts/clone-utils.mjs new file mode 100644 index 0000000..88d94ae --- /dev/null +++ b/scripts/clone-utils.mjs @@ -0,0 +1,57 @@ +export 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)) + }) +} + +export function parseJson(input) { + const normalized = String(input || '').replace(/^\uFEFF/, '').trim() + return normalized ? JSON.parse(normalized) : {} +} + +export function nowIso() { + return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +export function numeric(value, fallback = 0) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export function stringValue(value) { + return String(value || '').trim() +} + +export function isIntegerString(value) { + return /^[0-9]+$/.test(String(value || '')) +} + +export function isPositiveInteger(value) { + return /^[1-9][0-9]*$/.test(String(value || '')) +} + +export function isThreshold(value) { + return /^(0(\.[0-9]+)?|1(\.0+)?)$/.test(String(value || '')) +} + +export function quoteYaml(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +export function normalizeText(value) { + return String(value || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[`*_~"'“”‘’]/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +export function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/scripts/interview-answer-utils.mjs b/scripts/interview-answer-utils.mjs new file mode 100644 index 0000000..6b43e27 --- /dev/null +++ b/scripts/interview-answer-utils.mjs @@ -0,0 +1,90 @@ +import { escapeRegExp, normalizeText, numeric } from './clone-utils.mjs' + +export function labelBoundaryMatch(text, label) { + const normalizedLabel = normalizeText(label) + if (!normalizedLabel) return false + return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(normalizedLabel)}([^\\p{L}\\p{N}]|$)`, 'u').test(text) +} + +export function mapPredictionToOption(predictedResponse, options = [], fallback = '') { + const labels = options.map((option) => String(option?.label || '').trim()).filter(Boolean) + if (!labels.length) return fallback + + const text = normalizeText(predictedResponse) + const firstLine = normalizeText(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 = normalizeText(label) + return normalizedLabel === text || normalizedLabel === firstLine + }) + if (exactMatches.length === 1) return exactMatches[0] + + const prefixMatches = labels.filter((label) => { + const normalizedLabel = normalizeText(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] : fallback +} + +export function predictionCandidateText(candidate) { + if (!candidate) return '' + if (typeof candidate === 'string') return candidate + return String( + candidate.predicted_response || + candidate.response || + candidate.text || + candidate.content || + candidate.message || + '', + ) +} + +export function predictionCandidateConfidence(candidate, fallback) { + if (!candidate || typeof candidate === 'string') return fallback + return numeric( + candidate.confidence ?? + candidate.probability ?? + candidate.prob ?? + candidate.p ?? + candidate.score, + fallback, + ) +} + +export function rankedPredictionCandidates(prediction) { + const candidates = [] + const topConfidence = numeric(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 = predictionCandidateText(candidate) + if (!text) continue + candidates.push({ + text, + confidence: predictionCandidateConfidence(candidate, topConfidence), + index: candidates.length, + }) + } + + return candidates.sort((left, right) => right.confidence - left.confidence || left.index - right.index) +} diff --git a/scripts/loop-state-guard.mjs b/scripts/loop-state-guard.mjs index bbec756..405d92d 100644 --- a/scripts/loop-state-guard.mjs +++ b/scripts/loop-state-guard.mjs @@ -1,11 +1,8 @@ import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { nowIso } from './clone-utils.mjs' const DEFAULT_TTL_HOURS = 24 -export function nowIso() { - return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') -} - export function parseYamlScalar(value) { const raw = value.trim() if (raw === 'null') return null diff --git a/scripts/predict-interview-answer.mjs b/scripts/predict-interview-answer.mjs index 919a6d9..5d97e7d 100644 --- a/scripts/predict-interview-answer.mjs +++ b/scripts/predict-interview-answer.mjs @@ -3,145 +3,28 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { interviewHistoryPath, interviewStatePath } from './clone-paths.mjs' import { resolveCloneToken } from './clone-auth.mjs' +import { cloneMcpClientInfo, cloneMcpEndpoint, cloneMcpRpc } from './clone-mcp-client.mjs' +import { numeric, parseJson, readStdin } from './clone-utils.mjs' +import { mapPredictionToOption, rankedPredictionCandidates } from './interview-answer-utils.mjs' import { appendLoopHistory, parseState } from './loop-state-guard.mjs' -const CLIENT_VERSION = '0.14.6' - -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 numeric(value, fallback = 0) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : fallback -} - -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) -} - -export function mapPredictionToOption(predictedResponse, options = []) { - const labels = options.map((option) => String(option?.label || '').trim()).filter(Boolean) - if (!labels.length) return '' - - 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] : '' -} - -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 text ? JSON.parse(text) : null -} - -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 submitFeedback({ endpoint, token, predictionId, status, actualResponse, mcpSessionId }) { if (!predictionId) return const args = { prediction_id: predictionId, status } if (actualResponse) args.actual_response = actualResponse - await rpc(endpoint, token, 'tools/call', { name: 'submit_feedback', arguments: args }, mcpSessionId) + await cloneMcpRpc(endpoint, token, 'tools/call', { name: 'submit_feedback', arguments: args }, mcpSessionId) } 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 = { @@ -152,7 +35,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', @@ -173,11 +56,11 @@ async function clonePredictNextPrompt({ agent, agentInput, threshold, sessionId } function historyPath(root) { - return resolve(root, '.claude', 'clone-interview.history.local.jsonl') + return interviewHistoryPath(root) } function statePath(root) { - return resolve(root, '.claude', 'clone-interview.local.md') + return interviewStatePath(root) } function appendInterviewHistory(root, record) { @@ -212,9 +95,15 @@ function buildAgentInput({ state, question, options, answerKind, lastAssistantMe return `Clone Interview topic: ${frontmatter.topic || ''} -Current Clone Interview spec / ledger: +Current Clone Interview spec: ${spec || '(empty spec)'} +Pay special attention to these sections when present: +- Goal Contract: what goal, outcome, scope, non-goals, decision boundaries, constraints, and acceptance criteria are already settled. +- Decision Ledger: which decisions came from code, user, Clone auto-answer, or Clone escalation. +- Plan Draft: what implementation phases, likely touched areas, tests/checks, risks, and acceptance checklist are emerging. +- Readiness Audit: what gap this question should close before execution handoff. + Latest agent context: ${latest || '(no latest assistant message provided)'} @@ -228,52 +117,13 @@ ${formatOptions(options)} Predict the exact answer this user would give to the interview question. Rules: - Write as the user, not as the agent. -- Preserve concrete scope, constraints, non-goals, and acceptance criteria. +- Preserve concrete scope, constraints, non-goals, decision boundaries, acceptance criteria, and plan impact. - If options are provided and one clearly matches, answer with that option label. - If confidence is low, still return the best prediction; the caller will escalate.` } -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 numeric( - candidate.confidence ?? - candidate.probability ?? - candidate.prob ?? - candidate.p ?? - candidate.score, - fallback, - ) -} - -function rankedCandidates(prediction) { - const candidates = [] - const topConfidence = numeric(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) -} - function selectAnswer({ prediction, answerKind, options }) { - const ranked = rankedCandidates(prediction) + const ranked = rankedPredictionCandidates(prediction) if (answerKind === 'choice' && Array.isArray(options) && options.length) { for (const candidate of ranked) { const mapped = mapPredictionToOption(candidate.text, options) diff --git a/scripts/setup-clone-interview.mjs b/scripts/setup-clone-interview.mjs index 89e354e..13c0838 100644 --- a/scripts/setup-clone-interview.mjs +++ b/scripts/setup-clone-interview.mjs @@ -1,13 +1,21 @@ #!/usr/bin/env node import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { dirname } from 'node:path' +import { + DEFAULT_INTERVIEW_OUTPUT_PATH, + claudeDir, + interviewHistoryPath, + interviewStatePath, + projectLocalPath as resolveProjectLocalPath, +} from './clone-paths.mjs' +import { isPositiveInteger, isThreshold, nowIso, quoteYaml } from './clone-utils.mjs' const args = process.argv.slice(2) const topicParts = [] let maxQuestions = '12' let mode = 'deep' -let outputPath = '.claude/clone-interview.local.md' +let outputPath = DEFAULT_INTERVIEW_OUTPUT_PATH let cloneThreshold = '0.75' let cloneAgent = process.env.CODEX_THREAD_ID ? 'Codex Clone Interview' : 'Claude Code Clone Interview' let autoAnswer = true @@ -46,26 +54,13 @@ function fail(message) { process.exit(1) } -function quoteYaml(value) { - return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` -} - -function isPositiveInteger(value) { - return /^[1-9][0-9]*$/.test(String(value || '')) -} - -function isThreshold(value) { - return /^(0(\.[0-9]+)?|1(\.0+)?)$/.test(value) -} - function projectLocalPath(path) { const root = process.cwd() - const resolved = isAbsolute(path) ? resolve(path) : resolve(root, path) - const rel = relative(root, resolved) - if (rel.startsWith('..') || isAbsolute(rel)) { + const localPath = resolveProjectLocalPath(root, path) + if (!localPath.isInsideProject) { fail(`Error: --output must stay inside the current project. Received: ${path}`) } - return { resolved, display: rel || '.' } + return localPath } for (let index = 0; index < args.length;) { @@ -141,15 +136,14 @@ if (!topic) { } const root = process.cwd() -const claudeDir = resolve(root, '.claude') -mkdirSync(claudeDir, { recursive: true }) +mkdirSync(claudeDir(root), { recursive: true }) -const statePath = resolve(claudeDir, 'clone-interview.local.md') -const historyPath = resolve(claudeDir, 'clone-interview.history.local.jsonl') +const statePath = interviewStatePath(root) +const historyPath = interviewHistoryPath(root) const output = projectLocalPath(outputPath) mkdirSync(dirname(output.resolved), { recursive: true }) -const startedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') +const startedAt = nowIso() const sessionId = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID || '' const spec = `--- active: true @@ -179,31 +173,89 @@ ${topic} ### User Decisions -- (Record explicit user decisions here.) +- (Record explicit user decisions here as \`[from-user] ...\`.) + +### Clone Predictions + +- (Record high-confidence Clone answers as \`[from-clone][auto] ...\`.) +- (Record low-confidence suggestions as \`[from-clone][escalated] ...\` before asking the user.) + +## Goal Contract + +### Why + +- (Why this goal matters to the user or business.) + +### Desired Outcome + +- (The concrete end state the user wants.) + +### Target User + +- (Who the work is for.) -### Scope +### In Scope -- (Record in-scope behavior here.) +- (Behaviors, surfaces, or deliverables this plan should include.) -### Non-Goals +### Out of Scope -- (Record out-of-scope behavior here.) +- (Explicit non-goals and deferred work.) + +### Decision Boundaries + +- (What the agent may decide without confirmation, and what must go back to the user.) ### Constraints -- (Record technical, product, timeline, privacy, and compatibility constraints here.) +- (Technical, product, timeline, privacy, compatibility, or launch constraints.) ### Acceptance Criteria -- (Record observable completion criteria here.) +- [ ] (Observable, testable completion criterion.) + +## Decision Ledger + +| Source | Decision | Plan Impact | +| --- | --- | --- | +| \`[from-code][auto-confirmed]\` | (descriptive repo fact) | (how it shapes the plan) | +| \`[from-user]\` | (user decision) | (how it changes scope, tests, or implementation) | +| \`[from-clone][auto]\` | (Clone-predicted answer accepted above threshold) | (how it changes the plan draft) | +| \`[from-clone][escalated]\` | (low-confidence suggestion) | (question asked to the user before relying on it) | + +## Plan Draft + +### Implementation Phases + +1. (Phase name and goal.) + +### Likely Touched Areas -### Verification +- (Files, directories, modules, commands, docs, or config likely affected.) + +### Required Tests / Checks - (Record required tests, checks, demos, or review gates here.) -### Restated Goal +### Risks and Unknowns + +- (Risks, unclear decisions, or assumptions that could change execution.) + +### Acceptance Checklist + +- [ ] (Checklist item tied to the acceptance criteria.) + +## Readiness Audit + +- [ ] One-sentence goal is unambiguous. +- [ ] In-scope and out-of-scope are separated. +- [ ] Acceptance criteria are observable or testable. +- [ ] Product/business decisions are resolved or listed as open questions. +- [ ] Plan Draft includes phase, likely touched areas, tests/checks, and risks. + +## Execution Handoff -- (Before closing, restate the final one-sentence goal and get user confirmation.) +- (After the Readiness Audit passes, ask the user to choose: Refine plan, Start Clone Loop with this plan, Implement manually from this plan, or Stop here.) ## Interview Operating Rules @@ -211,11 +263,12 @@ ${topic} - Auto-confirm only exact repo facts, and mark them with \`[from-code][auto-confirmed]\`. - Before asking human-judgment questions, ask Clone to predict the user's answer when \`auto_answer\` is true. - Use Clone-predicted answers only when confidence is greater than or equal to \`clone_threshold\`; otherwise escalate to the user. -- Ask the user for goals, scope, acceptance criteria, product tradeoffs, business logic, and non-goals. -- Ask one question at a time. -- Structure free-form answers that carry scope or constraints, then confirm nothing was lost before treating them as final. -- In \`quick\` mode, close goal, output, and acceptance criteria. -- In \`deep\` mode, close goal, audience, constraints, outputs, acceptance criteria, non-goals, and verification. +- Ask the highest-impact unresolved question in this order: goal, outcome, scope/non-goals, decision boundaries, constraints, acceptance criteria, plan risks. +- Ask one question at a time using this frame: Current understanding, Blocked decision, Clone predicted answer or escalation, Question, Plan impact. +- Update the Goal Contract, Decision Ledger, Plan Draft, and Readiness Audit after every answer, including Clone auto-answers. +- In \`quick\` mode, close goal, outcome, scope, acceptance criteria, and a minimal Plan Draft. +- In \`deep\` mode, close goal, audience, decision boundaries, constraints, non-goals, acceptance criteria, risks, and Execution Handoff. +- Before closing, run the Readiness Audit. If any item fails, ask the single question that would most improve the plan. ` writeFileSync(statePath, spec) diff --git a/scripts/setup-clone-loop.mjs b/scripts/setup-clone-loop.mjs index b9ac3a4..b3311bf 100644 --- a/scripts/setup-clone-loop.mjs +++ b/scripts/setup-clone-loop.mjs @@ -1,16 +1,15 @@ #!/usr/bin/env node import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { formatIterationPromptLine } from './clone-display.mjs' +import { claudeDir, loopHistoryPath, loopStatePath } from './clone-paths.mjs' +import { isIntegerString, isThreshold, nowIso, quoteYaml } from './clone-utils.mjs' const args = process.argv.slice(2) const promptParts = [] let maxIterations = '0' let cloneThreshold = '0.6' let cloneAgent = process.env.CODEX_THREAD_ID ? 'Codex Clone Loop' : 'Claude Code Clone Loop' -const ANSI_BOLD = '\u001b[1m' -const ANSI_PURPLE = '\u001b[35m' -const ANSI_RESET = '\u001b[0m' function usage() { console.log(`Clone Loop - iterative development loop with Clone-predicted next prompts @@ -43,33 +42,6 @@ function fail(message) { process.exit(1) } -function isThreshold(value) { - return /^(0(\.[0-9]+)?|1(\.0+)?)$/.test(value) -} - -function quoteYaml(value) { - return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` -} - -function purple(value) { - return `${ANSI_PURPLE}${value}${ANSI_RESET}` -} - -function purpleBold(value) { - return `${ANSI_BOLD}${ANSI_PURPLE}${value}${ANSI_RESET}` -} - -function formatIterationPromptLine({ iteration, prompt }) { - const [firstLine = '', ...remainingLines] = String(prompt || '') - .trim() - .split(/\r?\n/) - .map((line) => line.trim()) - const continuation = remainingLines.length - ? `\n${remainingLines.map((line) => purpleBold(`> ${line}`)).join('\n')}` - : '' - return `${purpleBold(`Iteration ${iteration} : ${firstLine}`)}${continuation}` -} - for (let index = 0; index < args.length;) { const arg = args[index] if (arg === '-h' || arg === '--help') { @@ -79,7 +51,7 @@ for (let index = 0; index < args.length;) { if (arg === '--max-iterations') { const value = args[index + 1] - if (!value || !/^[0-9]+$/.test(value)) { + if (!value || !isIntegerString(value)) { fail('Error: --max-iterations requires a positive integer or 0.') } maxIterations = value @@ -119,10 +91,10 @@ if (!prompt) { } const sessionId = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID || '' -const claudeDir = join(process.cwd(), '.claude') -mkdirSync(claudeDir, { recursive: true }) +const root = process.cwd() +mkdirSync(claudeDir(root), { recursive: true }) -const startedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') +const startedAt = nowIso() const state = `--- active: true iteration: 1 @@ -136,11 +108,11 @@ started_at: "${startedAt}" ${prompt} ` -writeFileSync(join(claudeDir, 'clone-loop.local.md'), state) +writeFileSync(loopStatePath(root), state) try { appendFileSync( - join(claudeDir, 'clone-loop.history.local.jsonl'), + loopHistoryPath(root), `${JSON.stringify({ ts: startedAt, event: 'loop-start', diff --git a/skills/clone-cancel-loop/SKILL.md b/skills/clone-cancel-loop/SKILL.md index 9dc5044..5e49997 100644 --- a/skills/clone-cancel-loop/SKILL.md +++ b/skills/clone-cancel-loop/SKILL.md @@ -7,11 +7,10 @@ description: Cancel the active Clone Loop in the current workspace. Use this skill when the user asks to cancel or stop the active Clone Loop. -Check for `.claude/clone-loop.local.md` in the current workspace. If it does not exist, say there is no active Clone Loop. If it exists, read the `iteration:` field, remove the file, and report the cancelled iteration. +Run the repo-local cancel script from the current workspace: -Use shell commands equivalent to: - -```bash -test -f .claude/clone-loop.local.md && echo EXISTS || echo NOT_FOUND -rm .claude/clone-loop.local.md +```sh +node "${PLUGIN_ROOT}/scripts/cancel-clone-loop.mjs" ``` + +The script checks `.claude/clone-loop.local.md`, removes it if present, records a `loop-cancel` history event, and reports the cancelled iteration. diff --git a/skills/clone-interview/SKILL.md b/skills/clone-interview/SKILL.md index 3f83eee..9579fdd 100644 --- a/skills/clone-interview/SKILL.md +++ b/skills/clone-interview/SKILL.md @@ -22,11 +22,14 @@ Interview rules: - Before asking the user for goals, audience, scope, non-goals, product tradeoffs, business logic, acceptance criteria, constraints, or verification, run `scripts/predict-interview-answer.mjs` with the question. - If the script returns `decision: "auto"`, record the predicted answer in the spec and keep interviewing. - If the script returns `decision: "escalate"`, ask the user directly and record the user's answer. -- Ask one question at a time. +- Keep the Goal Contract, Decision Ledger, Plan Draft, Readiness Audit, and Execution Handoff current after every answer. +- Ask one question at a time using this exact frame: `Current understanding`, `Blocked decision`, `Clone predicted answer` or `escalated`, `Question`, and `Plan impact`. +- Prioritize questions in this order: goal, outcome, scope/non-goals, decision boundaries, constraints, acceptance criteria, plan risks. - For free-form answers that carry scope or constraints, structure the answer and confirm nothing was lost before recording it. -- In `quick` mode, close goal, output, and acceptance criteria. -- In `deep` mode, close goal, audience, constraints, outputs, acceptance criteria, non-goals, and verification. -- Before closing, restate the final one-sentence goal and get user confirmation, then update the spec markdown. +- In `quick` mode, close goal, outcome, scope, acceptance criteria, and a minimal Plan Draft. +- In `deep` mode, close goal, audience, decision boundaries, constraints, non-goals, acceptance criteria, risks, and Execution Handoff. +- Before closing, run the Readiness Audit. If any item fails, ask the single question that most improves the Plan Draft. +- When the Readiness Audit passes, ask the user to choose one handoff path: Refine plan, Start Clone Loop with this plan, Implement manually from this plan, or Stop here. Supported options: @@ -36,4 +39,4 @@ Supported options: - `--clone-threshold `: confidence threshold for Clone auto-answers, default `0.75`. - `--no-auto-answer`: disable Clone-predicted answers and always ask the user. -Clone Interview v1 is plugin-only for question generation. The agent generates interview questions; Clone MCP predicts how the user would answer and gates auto-answering by confidence. Do not call Clone MCP as an interview question generator unless a future Clone MCP interview tool is explicitly available. +Clone Interview v1 is plugin-only for question generation. The agent generates interview questions; Clone MCP predicts how the user would answer and gates auto-answering by confidence. The goal is a plan-ready spec: do not keep asking after the Readiness Audit passes, and do not start implementation without an explicit handoff choice. diff --git a/tests/cancel-clone-loop.test.mjs b/tests/cancel-clone-loop.test.mjs new file mode 100644 index 0000000..ed554c4 --- /dev/null +++ b/tests/cancel-clone-loop.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const pluginRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const scriptPath = join(pluginRoot, 'scripts', 'cancel-clone-loop.mjs') + +function runCancel(workdir) { + return spawnSync(process.execPath, [scriptPath], { + cwd: workdir, + encoding: 'utf8', + }) +} + +describe('Clone Loop cancel script', () => { + it('reports no active loop when state is absent', () => { + const workdir = mkdtempSync(join(tmpdir(), 'clone-loop-cancel-empty-')) + + try { + const result = runCancel(workdir) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /No active Clone Loop found/) + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + }) + + it('removes active loop state and records cancellation history', () => { + const workdir = mkdtempSync(join(tmpdir(), 'clone-loop-cancel-')) + const claudeDir = join(workdir, '.claude') + + try { + mkdirSync(claudeDir, { recursive: true }) + writeFileSync( + join(claudeDir, 'clone-loop.local.md'), + `--- +active: true +iteration: 7 +session_id: "session-123" +--- + +Original task +`, + ) + + const result = runCancel(workdir) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /Cancelled Clone Loop \(was at iteration 7\)/) + assert.equal(existsSync(join(claudeDir, 'clone-loop.local.md')), false) + + const history = readFileSync(join(claudeDir, 'clone-loop.history.local.jsonl'), 'utf8') + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line)) + assert.equal(history.length, 1) + assert.equal(history[0].event, 'loop-cancel') + assert.equal(history[0].iteration, '7') + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/clone-interview-plugin.test.mjs b/tests/clone-interview-plugin.test.mjs index 6fe5ec6..46007a8 100644 --- a/tests/clone-interview-plugin.test.mjs +++ b/tests/clone-interview-plugin.test.mjs @@ -14,12 +14,16 @@ describe('Clone Interview plugin surface', () => { it('publishes a Claude /clone:interview command backed by the setup script', () => { const command = read('commands/interview.md') - assert.match(command, /description: "Clarify requirements into a Clone Interview spec"/) + assert.match(command, /description: "Clarify a goal into a Clone Interview plan"/) assert.match(command, /argument-hint: "TOPIC \[--max-questions N\] \[--mode quick\|deep\] \[--output PATH\]"/) assert.match(command, /setup-clone-interview\.mjs/) assert.match(command, /AskUserQuestion/) assert.match(command, /Ask one question at a time/) - assert.match(command, /one-sentence goal/) + assert.match(command, /Current understanding/) + assert.match(command, /Blocked decision/) + assert.match(command, /Plan impact/) + assert.match(command, /Readiness Audit/) + assert.match(command, /executable plan/) }) it('publishes a Codex clone-interview skill with plugin-only interview rules', () => { @@ -33,6 +37,10 @@ describe('Clone Interview plugin surface', () => { assert.match(skill, /Inspect repository facts first/) assert.match(skill, /decision: "auto"/) assert.match(skill, /decision: "escalate"/) + assert.match(skill, /Current understanding/) + assert.match(skill, /Blocked decision/) + assert.match(skill, /Plan impact/) + assert.match(skill, /Readiness Audit/) assert.match(skill, /plugin-only for question generation/) }) @@ -47,7 +55,10 @@ describe('Clone Interview plugin surface', () => { assert.match(codexHelp, /clone-interview/) assert.match(hooks, /interview-question-hook\.mjs/) assert.match(readme, /\/clone:interview ""/) - assert.match(readme, /Clone Interview is the requirements side of Clone/) + assert.match(readme, /Clone Interview is the goal-to-plan side of Clone/) + assert.match(readme, /Goal Contract/) + assert.match(readme, /Plan Draft/) + assert.match(readme, /Readiness Audit/) assert.match(readme, /Low-confidence questions escalate to you/) assert.ok( codexManifest.interface.defaultPrompt.some((prompt) => /Clone Interview/.test(prompt)), diff --git a/tests/clone-interview-predict.test.mjs b/tests/clone-interview-predict.test.mjs index 77fb1a0..5f6af6a 100644 --- a/tests/clone-interview-predict.test.mjs +++ b/tests/clone-interview-predict.test.mjs @@ -20,7 +20,39 @@ function writeInterviewState(workdir, overrides = {}) { agent: 'Claude Code Clone Interview', autoAnswer: true, outputPath: '.claude/clone-interview.local.md', - body: '# Clone Interview\n\n## Topic\n\nAdd billing\n\n## Working Ledger\n\n### Code Facts\n\n- [from-code][auto-confirmed] Node ESM package.', + body: `# Clone Interview + +## Topic + +Add billing + +## Working Ledger + +### Code Facts + +- [from-code][auto-confirmed] Node ESM package. + +## Goal Contract + +- **Why:** TBD +- **Desired outcome:** TBD + +## Decision Ledger + +| Source | Decision | Plan impact | +|---|---|---| +| [from-code][auto-confirmed] | Node ESM package | Tests should use node:test. | + +## Plan Draft + +### Implementation Phases + +- TBD + +## Readiness Audit + +- [ ] One-sentence goal is unambiguous. +`, ...overrides, } mkdirSync(join(workdir, '.claude'), { recursive: true }) @@ -173,6 +205,9 @@ describe('Clone Interview prediction script', () => { assert.equal(output.confidence, 0.91) assert.deepEqual(calls.map((call) => call.params?.name || call.method), ['initialize', 'predict_next_prompt', 'submit_feedback']) assert.match(calls[1].params.arguments.agent_input, /Current Clone Interview spec/) + assert.match(calls[1].params.arguments.agent_input, /Goal Contract/) + assert.match(calls[1].params.arguments.agent_input, /Decision Ledger/) + assert.match(calls[1].params.arguments.agent_input, /Plan Draft/) assert.match(calls[1].params.arguments.agent_input, /Should import support CSV only/) const history = readFileSync(join(workdir, '.claude', 'clone-interview.history.local.jsonl'), 'utf8') diff --git a/tests/clone-interview-setup.test.mjs b/tests/clone-interview-setup.test.mjs index 0661ed0..610f74a 100644 --- a/tests/clone-interview-setup.test.mjs +++ b/tests/clone-interview-setup.test.mjs @@ -53,6 +53,11 @@ describe('Clone Interview setup script', () => { assert.match(state, /auto_answer: true/) assert.match(state, /output_path: "\.claude\/clone-interview\.local\.md"/) assert.match(state, /# Clone Interview/) + assert.match(state, /## Goal Contract/) + assert.match(state, /## Decision Ledger/) + assert.match(state, /## Plan Draft/) + assert.match(state, /## Readiness Audit/) + assert.match(state, /## Execution Handoff/) assert.match(state, /\[from-code\]\[auto-confirmed\]/) const history = readFileSync(join(workdir, '.claude', 'clone-interview.history.local.jsonl'), 'utf8') diff --git a/tests/release-automation.test.mjs b/tests/release-automation.test.mjs index cbe51a6..42e41e7 100644 --- a/tests/release-automation.test.mjs +++ b/tests/release-automation.test.mjs @@ -15,7 +15,7 @@ function writeFixture(rootDir, path, contents) { } describe('release automation', () => { - it('bumps plugin version across manifests and hook clients', () => { + it('bumps plugin version across manifests', () => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'clone-release-fixture-')) try { @@ -29,10 +29,6 @@ describe('release automation', () => { '.codex-plugin/plugin.json', JSON.stringify({ name: 'clone-loop', version: '0.2.7', description: 'Clone Loop' }, null, 2), ) - writeFixture(fixtureRoot, 'hooks/stop-hook.mjs', "const CLIENT_VERSION = '0.2.7'\n") - writeFixture(fixtureRoot, 'hooks/ask-user-question-hook.mjs', "const CLIENT_VERSION = '0.2.7'\n") - writeFixture(fixtureRoot, 'scripts/predict-interview-answer.mjs', "const CLIENT_VERSION = '0.2.7'\n") - const result = spawnSync( process.execPath, [join(root, 'scripts/bump-plugin-version.mjs'), '--root', fixtureRoot, '--part', 'patch'], @@ -42,9 +38,6 @@ describe('release automation', () => { assert.equal(result.status, 0, result.stderr || result.stdout) assert.equal(JSON.parse(readFileSync(join(fixtureRoot, '.claude-plugin/plugin.json'), 'utf8')).version, '0.2.8') assert.equal(JSON.parse(readFileSync(join(fixtureRoot, '.codex-plugin/plugin.json'), 'utf8')).version, '0.2.8') - assert.match(readFileSync(join(fixtureRoot, 'hooks/stop-hook.mjs'), 'utf8'), /CLIENT_VERSION = '0\.2\.8'/) - assert.match(readFileSync(join(fixtureRoot, 'hooks/ask-user-question-hook.mjs'), 'utf8'), /CLIENT_VERSION = '0\.2\.8'/) - assert.match(readFileSync(join(fixtureRoot, 'scripts/predict-interview-answer.mjs'), 'utf8'), /CLIENT_VERSION = '0\.2\.8'/) assert.match(result.stdout, /0\.2\.7 -> 0\.2\.8/) } finally { rmSync(fixtureRoot, { recursive: true, force: true }) @@ -61,6 +54,7 @@ describe('release automation', () => { assert.match(workflow, /node scripts\/bump-plugin-version\.mjs --part patch/) assert.match(workflow, /TAG="clone--v\$\{VERSION\}"/) assert.match(workflow, /git add .*\.claude-plugin\/plugin\.json .*\.codex-plugin\/plugin\.json/) - assert.match(workflow, /git add .*scripts\/predict-interview-answer\.mjs/) + assert.doesNotMatch(workflow, /git add .*hooks\/stop-hook\.mjs/) + assert.doesNotMatch(workflow, /git add .*scripts\/predict-interview-answer\.mjs/) }) }) diff --git a/tests/repo-identity.test.mjs b/tests/repo-identity.test.mjs index 3314873..abdee11 100644 --- a/tests/repo-identity.test.mjs +++ b/tests/repo-identity.test.mjs @@ -51,12 +51,15 @@ describe('repository identity', () => { it('uses clone-loop for package and Clone MCP client identity', () => { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + const client = readFileSync(join(root, 'scripts', 'clone-mcp-client.mjs'), 'utf8') const stopHook = readFileSync(join(root, 'hooks', 'stop-hook.mjs'), 'utf8') const askHook = readFileSync(join(root, 'hooks', 'ask-user-question-hook.mjs'), 'utf8') assert.equal(pkg.name, 'clone-loop-tests') - assert.match(stopHook, /clientInfo: \{ name: 'clone-loop'/) - assert.match(askHook, /clientInfo: \{ name: 'clone-loop'/) + assert.match(client, /CLONE_MCP_CLIENT_NAME = 'clone-loop'/) + assert.match(client, /PLUGIN_VERSION/) + assert.match(stopHook, /clientInfo: cloneMcpClientInfo\(\)/) + assert.match(askHook, /clientInfo: cloneMcpClientInfo\(\)/) }) it('installer can automatically star clone-loop through GitHub CLI', () => {