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
338 changes: 110 additions & 228 deletions README.md

Large diffs are not rendered by default.

119 changes: 113 additions & 6 deletions hooks/ask-user-question-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { resolveCloneToken } from '../scripts/clone-auth.mjs'
import {
HISTORY_WINDOW_TURNS,
assistantTextsThisIteration,
formatConversationHistory,
iterationBlocksThisIteration,
iterationTimelinesByBoundary,
loadInjectedUserTurns,
loadIterationBoundaries,
} from '../scripts/conversation-context.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 Down Expand Up @@ -280,12 +289,94 @@ function formatOptions(options) {
.join('\n')
}

function buildQuestionAgentInput({ state, question, questionIndex, questionCount, threshold }) {
return `Original Clone Loop prompt:
${state.prompt.trim()}
function safeAssistantTextsThisIteration(transcriptPath, sinceTs) {
if (!transcriptPath || !existsSync(transcriptPath)) return []
try {
return assistantTextsThisIteration(transcriptPath, sinceTs)
} catch {
return []
}
}

Clone Loop iteration: ${state.frontmatter.iteration || 'unknown'}
Clone threshold: ${threshold}
function safeIterationBlocksThisIteration(transcriptPath, sinceTs) {
if (!transcriptPath || !existsSync(transcriptPath)) return []
try {
return iterationBlocksThisIteration(transcriptPath, sinceTs)
} catch {
return []
}
}

function safePriorIterTimelines(transcriptPath, historyPath) {
if (!transcriptPath || !existsSync(transcriptPath)) return []
try {
const boundaries = loadIterationBoundaries(historyPath)
const all = iterationTimelinesByBoundary(transcriptPath, boundaries)
return all.slice(0, -1)
} catch {
return []
}
}

function findLastContinueTs(historyPath) {
if (!historyPath || !existsSync(historyPath)) return ''
let raw
try {
raw = readFileSync(historyPath, 'utf8')
} catch {
return ''
}
let lastContinueTs = ''
let loopStartTs = ''
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue
let record
try {
record = JSON.parse(line.replace(/^/, ''))
} catch {
continue
}
if (!record || typeof record !== 'object') continue
const ts = typeof record.ts === 'string' ? record.ts : ''
if (!ts) continue
if (record.event === 'stop' && record.decision === 'continue') {
if (ts > lastContinueTs) lastContinueTs = ts
} else if (record.event === 'loop-start' && !loopStartTs) {
loopStartTs = ts
}
}
return lastContinueTs || loopStartTs || ''
}

function buildQuestionAgentInput({
state,
question,
questionIndex,
questionCount,
threshold,
transcriptPath,
historyPath,
}) {
const injectedUserTurns = loadInjectedUserTurns(historyPath)
const sinceTs = findLastContinueTs(historyPath)
const iterationBlocks = safeIterationBlocksThisIteration(transcriptPath, sinceTs)
const assistantTexts = iterationBlocks.length
? []
: safeAssistantTextsThisIteration(transcriptPath, sinceTs)
const priorIterTimelines = safePriorIterTimelines(transcriptPath, historyPath)

const conversationContext = formatConversationHistory({
promptText: state.prompt,
iteration: state.frontmatter.iteration || 'unknown',
threshold,
injectedUserTurns,
assistantTexts,
iterationBlocks,
priorIterTimelines,
windowTurns: HISTORY_WINDOW_TURNS,
})

return `${conversationContext}

Claude called AskUserQuestion during the active Clone Loop.
Predict the exact natural-language answer this user would give.
Expand Down Expand Up @@ -323,6 +414,16 @@ function allowAnswer({ toolInput, answers, confidence, threshold }) {
)
}

async function safeReject({ predictionId, mcpSessionId }) {
if (!predictionId) return
try {
await submitFeedback({ predictionId, status: 'rejected', mcpSessionId })
appendHistory({ event: 'feedback-sent', source: 'ask-user-question', prediction_id: predictionId, status: 'rejected' })
} catch (error) {
appendHistory({ event: 'feedback-sent', source: 'ask-user-question', prediction_id: predictionId, status: 'rejected', error: error?.message || String(error) })
}
}

async function main() {
const hookInput = parseJson(await readStdin())
if (hookInput.tool_name && hookInput.tool_name !== 'AskUserQuestion') return
Expand All @@ -338,6 +439,8 @@ async function main() {
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) : ''
Expand Down Expand Up @@ -375,9 +478,12 @@ async function main() {
questionIndex,
questionCount: questions.length,
threshold: cloneThreshold,
transcriptPath: hookInput.transcript_path ? String(hookInput.transcript_path) : '',
historyPath: LOOP_HISTORY_FILE,
}),
threshold: cloneThreshold,
sessionId: hookSession,
sessionId: cloneSessionId || undefined,
mcpSessionId: mcpSessionIdInitial,
})
} catch (error) {
const fallbackAnswer = String(options[0]?.label || '').trim()
Expand Down Expand Up @@ -422,6 +528,7 @@ async function main() {
prediction_id: prediction.id || null,
status: prediction.status || null,
})
await safeReject({ predictionId: prediction.id, mcpSessionId: mcpSessionIdInitial })
return
}

Expand Down
122 changes: 88 additions & 34 deletions hooks/stop-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
import { appendFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { resolveCloneToken } from '../scripts/clone-auth.mjs'
import {
HISTORY_WINDOW_TURNS,
assistantTextsThisIteration,
formatConversationHistory,
iterationBlocksThisIteration,
iterationTimelinesByBoundary,
loadInjectedUserTurns,
loadIterationBoundaries,
} from '../scripts/conversation-context.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 Down Expand Up @@ -188,19 +197,34 @@ async function clonePredictNextPrompt({ agent, agentInput, threshold, sessionId
return JSON.parse(content.text)
}

function lastAssistantText(transcriptPath) {
const transcript = readFileSync(transcriptPath, 'utf8')
const texts = []
for (const line of transcript.split(/\r?\n/).filter(Boolean)) {
const parsed = JSON.parse(line.replace(/^\uFEFF/, ''))
if (parsed.message?.role !== 'assistant') continue
const content = parsed.message?.content
if (!Array.isArray(content)) continue
for (const block of content) {
if (block?.type === 'text') texts.push(block.text || '')
function findLastContinueTs(historyPath) {
if (!historyPath || !existsSync(historyPath)) return ''
let raw
try {
raw = readFileSync(historyPath, 'utf8')
} catch {
return ''
}
let lastContinueTs = ''
let loopStartTs = ''
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue
let record
try {
record = JSON.parse(line.replace(/^\uFEFF/, ''))
} catch {
continue
}
if (!record || typeof record !== 'object') continue
const ts = typeof record.ts === 'string' ? record.ts : ''
if (!ts) continue
if (record.event === 'stop' && record.decision === 'continue') {
if (ts > lastContinueTs) lastContinueTs = ts
} else if (record.event === 'loop-start' && !loopStartTs) {
loopStartTs = ts
}
}
return texts.at(-1) || ''
return lastContinueTs || loopStartTs || ''
}

async function main() {
Expand Down Expand Up @@ -252,31 +276,39 @@ async function main() {
return
}

let lastOutput = hookInput.last_assistant_message ? String(hookInput.last_assistant_message) : ''
if (!lastOutput) {
const transcriptPath = hookInput.transcript_path ? String(hookInput.transcript_path) : ''
if (!transcriptPath || !existsSync(transcriptPath)) {
console.error('Clone Loop: Transcript file not found; stopping.')
removeState()
return
}
const transcriptPath = hookInput.transcript_path ? String(hookInput.transcript_path) : ''
const sinceTs = findLastContinueTs(LOOP_HISTORY_FILE)

let iterationBlocks = []
let assistantTexts = []
if (transcriptPath && existsSync(transcriptPath)) {
try {
lastOutput = lastAssistantText(transcriptPath)
iterationBlocks = iterationBlocksThisIteration(transcriptPath, sinceTs)
} catch (error) {
console.error('Clone Loop: Failed to parse assistant message JSON.')
console.error('Clone Loop: Failed to parse transcript JSON; falling back to text-only extraction.')
console.error(`Error: ${error?.message || String(error)}`)
removeState()
return
iterationBlocks = []
}

if (!lastOutput) {
console.error('Clone Loop: No assistant messages found; stopping.')
removeState()
return
if (!iterationBlocks.length) {
try {
assistantTexts = assistantTextsThisIteration(transcriptPath, sinceTs)
} catch {
assistantTexts = []
}
}
}

if (!iterationBlocks.length && !assistantTexts.length) {
const fallback = hookInput.last_assistant_message ? String(hookInput.last_assistant_message) : ''
assistantTexts = fallback ? [fallback] : []
}

if (!iterationBlocks.length && !assistantTexts.length) {
console.error('Clone Loop: No assistant messages found; stopping.')
removeState()
return
}

const promptText = state.prompt.trim()
if (!promptText) {
console.error('Clone Loop: State file has no prompt text; stopping.')
Expand All @@ -289,14 +321,36 @@ async function main() {

const systemMessage = `Clone Loop iteration ${nextIteration}.`

const agentInput = `Original Clone Loop prompt:
${promptText}
const injectedUserTurns = loadInjectedUserTurns(LOOP_HISTORY_FILE)

Clone Loop iteration: ${nextIteration}
Clone threshold: ${cloneThreshold}
// Pull per-iteration transcript timelines so prior-iter assistant work
// (text + tool_use + tool_result) lands under each user (clone-prediction)
// marker. Drops the current-iteration entry — that one is already rendered
// as the footer through `iterationBlocks`.
let priorIterTimelines = []
if (transcriptPath && existsSync(transcriptPath)) {
try {
const boundaries = loadIterationBoundaries(LOOP_HISTORY_FILE)
const all = iterationTimelinesByBoundary(transcriptPath, boundaries)
// Last boundary is the in-progress iter; drop it so we don't double up.
priorIterTimelines = all.slice(0, -1)
} catch (error) {
console.error('Clone Loop: Failed to extract prior-iter timelines; continuing without them.')
console.error(`Error: ${error?.message || String(error)}`)
priorIterTimelines = []
}
}

Claude last_assistant_message:
${lastOutput}`
const agentInput = formatConversationHistory({
promptText,
iteration: nextIteration,
threshold: cloneThreshold,
injectedUserTurns,
assistantTexts,
iterationBlocks,
priorIterTimelines,
windowTurns: HISTORY_WINDOW_TURNS,
})

let prediction
try {
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/plugin-contract.test.mjs tests/api-key-manager.test.mjs tests/setup-clone-loop.test.mjs tests/stop-hook-v2.test.mjs tests/ask-user-question-hook.test.mjs tests/release-automation.test.mjs",
"test:mcp": "node --test tests/remote-mcp-connect.test.mjs",
"test": "node --test tests/api-key-manager.test.mjs tests/setup-clone-loop.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"
}
}
Loading
Loading