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
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="assets/clone-logo-spin-transparent.gif" alt="Clone logo spinning" width="180">
<br/>
<br/>
<strong>C L O N E</strong>
<strong>C L O N E - L O O P</strong>
<br/><br/>
<strong>Keep Agent working, with predicted next prompts.</strong>
<br/>
Expand All @@ -13,10 +13,18 @@
<a href="https://github.com/cloneisyou/clone-loop/tags">
<img src="https://img.shields.io/github/v/tag/cloneisyou/clone-loop?filter=clone-plugin-v*&label=version" alt="Version">
</a>
<img src="https://img.shields.io/badge/Claude%20Code-plugin-6f42c1" alt="Claude Code plugin">
<img src="https://img.shields.io/badge/Codex-plugin-111827" alt="Codex plugin">
<img src="https://img.shields.io/badge/Clone%20MCP-next--prompt-0ea5e9" alt="Clone MCP">
<img src="https://img.shields.io/github/license/cloneisyou/clone-loop?color=green" alt="License">
<a href="#quick-start">
<img src="https://img.shields.io/badge/Claude%20Code-plugin-6f42c1" alt="Claude Code plugin">
</a>
<a href="#codex-plugin">
<img src="https://img.shields.io/badge/Codex-plugin-111827" alt="Codex plugin">
</a>
<a href="https://clone.is/you">
<img src="https://img.shields.io/badge/Clone%20MCP-next--prompt-0ea5e9" alt="Clone MCP">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/github/license/cloneisyou/clone-loop?color=green" alt="License">
</a>
</p>

<p align="center">
Expand Down
59 changes: 15 additions & 44 deletions hooks/ask-user-question-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { appendFileSync, existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { resolveCloneToken } from '../scripts/clone-auth.mjs'
import {
Expand All @@ -12,19 +12,17 @@ import {
loadInjectedUserTurns,
loadIterationBoundaries,
} from '../scripts/conversation-context.mjs'
import {
appendLoopHistory,
validateActiveLoopState,
} from '../scripts/loop-state-guard.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.0'

function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
}

function appendHistory(record) {
try {
appendFileSync(LOOP_HISTORY_FILE, `${JSON.stringify({ ts: nowIso(), ...record })}\n`)
} catch {}
appendLoopHistory(LOOP_HISTORY_FILE, record)
}

function readStdin() {
Expand All @@ -43,34 +41,6 @@ function parseJson(input) {
return normalized ? JSON.parse(normalized) : {}
}

function parseYamlScalar(value) {
const raw = value.trim()
if (raw === 'null') return null
if (raw.startsWith('"') && raw.endsWith('"')) {
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
return raw
}

function parseState(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
if (!match) return null

const frontmatter = {}
for (const line of match[1].split(/\r?\n/)) {
const separator = line.indexOf(':')
if (separator < 0) continue
const key = line.slice(0, separator).trim()
const value = line.slice(separator + 1)
frontmatter[key] = parseYamlScalar(value)
}

return {
frontmatter,
prompt: match[2].replace(/^\r?\n/, ''),
}
}

function parseSse(text) {
const frames = text
.split(/\r?\n\r?\n/)
Expand Down Expand Up @@ -427,25 +397,26 @@ async function safeReject({ predictionId, mcpSessionId }) {
async function main() {
const hookInput = parseJson(await readStdin())
if (hookInput.tool_name && hookInput.tool_name !== 'AskUserQuestion') return
if (!existsSync(LOOP_STATE_FILE)) return

const state = parseState(readFileSync(LOOP_STATE_FILE, 'utf8'))
if (!state) {
appendHistory({ event: 'ask-user-question', decision: 'defer-corrupt-state' })
const hookSession = hookInput.session_id ? String(hookInput.session_id) : ''
const validation = validateActiveLoopState({
statePath: LOOP_STATE_FILE,
historyPath: LOOP_HISTORY_FILE,
hookSession,
source: 'ask-user-question',
})
if (!validation.ok) {
return
}
const { state } = validation

const {
session_id: stateSession,
clone_threshold: cloneThresholdRaw,
clone_agent: cloneAgentRaw,
clone_session_id: cloneSessionId,
mcp_session_id: mcpSessionIdInitial,
} = state.frontmatter

const hookSession = hookInput.session_id ? String(hookInput.session_id) : ''
if (stateSession && stateSession !== hookSession) return

const toolInput = hookInput.tool_input || {}
const questions = Array.isArray(toolInput.questions) ? toolInput.questions : []
if (!questions.length) return
Expand Down
75 changes: 21 additions & 54 deletions hooks/stop-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { appendFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { resolveCloneToken } from '../scripts/clone-auth.mjs'
import {
Expand All @@ -12,6 +12,11 @@ import {
loadInjectedUserTurns,
loadIterationBoundaries,
} from '../scripts/conversation-context.mjs'
import {
appendLoopHistory,
removeLoopState,
validateActiveLoopState,
} from '../scripts/loop-state-guard.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')
Expand All @@ -20,14 +25,8 @@ const ANSI_BOLD = '\u001b[1m'
const ANSI_PURPLE = '\u001b[35m'
const ANSI_RESET = '\u001b[0m'

function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
}

function appendHistory(record) {
try {
appendFileSync(LOOP_HISTORY_FILE, `${JSON.stringify({ ts: nowIso(), ...record })}\n`)
} catch {}
appendLoopHistory(LOOP_HISTORY_FILE, record)
}

function readStdin() {
Expand All @@ -46,37 +45,8 @@ function parseJson(input) {
return normalized ? JSON.parse(normalized) : {}
}

function parseYamlScalar(value) {
const raw = value.trim()
if (raw === 'null') return null
if (raw.startsWith('"') && raw.endsWith('"')) {
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
return raw
}

function parseState(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
if (!match) return null

const frontmatter = {}
for (const line of match[1].split(/\r?\n/)) {
const separator = line.indexOf(':')
if (separator < 0) continue
const key = line.slice(0, separator).trim()
const value = line.slice(separator + 1)
frontmatter[key] = parseYamlScalar(value)
}

return {
frontmatter,
prompt: match[2].replace(/^\r?\n/, ''),
content,
}
}

function removeState() {
rmSync(LOOP_STATE_FILE, { force: true })
removeLoopState(LOOP_STATE_FILE)
}

function block(reason, systemMessage) {
Expand Down Expand Up @@ -230,14 +200,22 @@ function findLastContinueTs(historyPath) {

async function main() {
const hookInput = parseJson(await readStdin())
if (!existsSync(LOOP_STATE_FILE)) return
const hookSession = hookInput.session_id ? String(hookInput.session_id) : ''

const state = parseState(readFileSync(LOOP_STATE_FILE, 'utf8'))
if (!state) {
console.error('Clone Loop: state file corrupted; frontmatter is missing.')
removeState()
const validation = validateActiveLoopState({
statePath: LOOP_STATE_FILE,
historyPath: LOOP_HISTORY_FILE,
hookSession,
source: 'stop',
})
if (!validation.ok) {
if (validation.reason === 'inactive') return
console.error(
`Clone Loop: ${validation.reason}; stale local loop state removed. Run /clone:loop again to start a new loop.`,
)
return
}
const { state } = validation

const {
iteration,
Expand All @@ -249,17 +227,6 @@ async function main() {

const cloneThreshold = cloneThresholdRaw || '0.6'
const cloneAgent = cloneAgentRaw || 'Claude Code Clone Loop'
const hookSession = hookInput.session_id ? String(hookInput.session_id) : ''

if (stateSession && stateSession !== hookSession) {
console.error(
`Clone Loop: session ID changed (state=${stateSession}, hook=${hookSession}); updating state and continuing.`,
)
// Patch state.content in memory so the iteration write below carries the
// corrected session_id into the file (avoids a separate disk round-trip).
state.content = state.content.replace(/^session_id: .*/m, `session_id: ${hookSession}`)
// Fall through — continue executing the hook with the new session.
}

if (!isIntegerString(iteration)) {
console.error('Clone Loop: state file corrupted; iteration is not numeric.')
Expand Down
47 changes: 10 additions & 37 deletions scripts/capture-tool-use.mjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
#!/usr/bin/env node

import { appendFileSync, existsSync, readFileSync } from 'node:fs'
import { appendLoopHistory, validateActiveLoopState } from './loop-state-guard.mjs'
import { resolve } from 'node:path'

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 CAP = 240

function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
}

function readStdin() {
return new Promise((resolveRead) => {
let input = ''
Expand All @@ -27,30 +23,6 @@ function parseJson(input) {
return normalized ? JSON.parse(normalized) : {}
}

function parseYamlScalar(value) {
const raw = value.trim()
if (raw === 'null') return null
if (raw.startsWith('"') && raw.endsWith('"')) {
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
return raw
}

function parseState(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
if (!match) return null

const frontmatter = {}
for (const line of match[1].split(/\r?\n/)) {
const separator = line.indexOf(':')
if (separator < 0) continue
const key = line.slice(0, separator).trim()
const value = line.slice(separator + 1)
frontmatter[key] = parseYamlScalar(value)
}
return { frontmatter }
}

function stringValue(value) {
return String(value || '').trim()
}
Expand Down Expand Up @@ -92,19 +64,20 @@ function summarize({ toolName, filePath, toolInput, toolResponse }) {
}

function appendHistory(record) {
appendFileSync(LOOP_HISTORY_FILE, `${JSON.stringify({ ts: nowIso(), ...record })}\n`)
appendLoopHistory(LOOP_HISTORY_FILE, record)
}

async function main() {
if (!existsSync(LOOP_STATE_FILE)) return

const hookInput = parseJson(await readStdin())
const state = parseState(readFileSync(LOOP_STATE_FILE, 'utf8'))
if (!state) return

const stateSession = stringValue(state.frontmatter.session_id)
const hookSession = hookInput.session_id ? stringValue(hookInput.session_id) : ''
if (stateSession && hookSession && stateSession !== hookSession) return
const validation = validateActiveLoopState({
statePath: LOOP_STATE_FILE,
historyPath: LOOP_HISTORY_FILE,
hookSession,
source: 'post-tool-use',
})
if (!validation.ok) return
const { state } = validation

const toolName = stringValue(hookInput.tool_name || hookInput.toolName)
if (!/^(Write|Edit|MultiEdit)$/.test(toolName)) return
Expand Down
15 changes: 14 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail

GITHUB_REPO="cloneisyou/clone-loop"

if command -v claude >/dev/null 2>&1; then
CLAUDE_BIN="claude"
elif command -v claude.exe >/dev/null 2>&1; then
Expand All @@ -13,7 +15,7 @@ fi

echo "Installing Clone with ${CLAUDE_BIN}..."

if ! "${CLAUDE_BIN}" plugin marketplace add cloneisyou/clone-loop@main; then
if ! "${CLAUDE_BIN}" plugin marketplace add "${GITHUB_REPO}@main"; then
echo "Marketplace add did not complete; refreshing clone-loop if it already exists."
"${CLAUDE_BIN}" plugin marketplace update clone-loop || true
fi
Expand All @@ -23,6 +25,17 @@ if ! "${CLAUDE_BIN}" plugin install clone@clone-loop --scope user; then
"${CLAUDE_BIN}" plugin update clone@clone-loop
fi

echo
if command -v gh >/dev/null 2>&1; then
if gh repo star "${GITHUB_REPO}" >/dev/null 2>&1; then
echo "Starred ${GITHUB_REPO}."
else
echo "Could not star automatically. Check GitHub CLI authentication with: gh auth status"
fi
else
echo "Skipping GitHub star because GitHub CLI is not installed."
fi

cat <<'NEXT'

Clone is installed.
Expand Down
Loading
Loading