#!/usr/bin/env node // Spike S3 — dialogues TUI : détection (registre + écran), extraction des options, frappes de réponse. // Types couverts : trust dialog (nouveau dossier), permission Bash, permission Write (édition), // AskUserQuestion, refus par Esc. (Approbation de plan : suivi manuel, voir VERDICT.) import { execSync } from 'node:child_process'; import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { ClaudeSession, sleep, setLogFile, log } from '../s1-resume/harness.mjs'; const CAPTURES = new URL('./captures/', import.meta.url).pathname; mkdirSync(CAPTURES, { recursive: true }); setLogFile(join(CAPTURES, 'steps.jsonl')); const results = {}; const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1)); function freshRepo(name) { const d = `/tmp/spike-s3-${name}`; rmSync(d, { recursive: true, force: true }); mkdirSync(d, { recursive: true }); execSync('git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init', { cwd: d, shell: '/bin/bash' }); return d; } // Extraction des options d'un dialogue depuis l'écran (regex du design moteur). // NB : le strip ANSI mange les espaces peints par déplacements de curseur → \s* après le point, // et les labels peuvent être concaténés (« Yes,Itrustthisfolder »). function extractOptions(screenTail) { const opts = []; for (const m of screenTail.matchAll(/^\s*(❯\s*)?(\d+)\.\s*(.+)$/gm)) { opts.push({ selected: !!m[1], n: m[2], label: m[3].trim().slice(0, 90) }); } return opts; } async function waitWaiting(S, timeoutMs = 120000) { return S.until('registre → waiting', () => { const r = S.registry(); return r && r.status === 'waiting' ? r : false; }, timeoutMs, 400); } // Répond au dialogue : essaie la touche chiffre seule, puis Entrée, puis flèches+Entrée. async function respond(S, digit) { const tries = []; S.write(digit); await sleep(2500); let r = S.registry(); tries.push({ method: `digit ${digit}`, statusAfter: r?.status }); if (r?.status !== 'waiting') return { ok: true, tries }; S.write('\r'); await sleep(2500); r = S.registry(); tries.push({ method: 'enter', statusAfter: r?.status }); if (r?.status !== 'waiting') return { ok: true, tries }; const downs = Math.max(0, parseInt(digit, 10) - 1); for (let i = 0; i < downs; i++) { S.write('\x1b[B'); await sleep(250); } S.write('\r'); await sleep(2500); r = S.registry(); tries.push({ method: `arrows x${downs} + enter`, statusAfter: r?.status }); return { ok: r?.status !== 'waiting', tries }; } try { // ---------- D1 : trust dialog sur dossier neuf + état ~/.claude.json ---------- log('=== D1 trust dialog ==='); const d1 = freshRepo('trust'); const claudeJsonBefore = existsSync(join(homedir(), '.claude.json')) ? JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')) : null; const trustBefore = claudeJsonBefore?.projects?.[d1] ?? null; const T = new ClaudeSession([], d1, 'trust', CAPTURES); // waitReady gère le trust par Entrée et logge l'écran du dialogue const regT = await T.waitReady(60000); await sleep(1500); const claudeJsonAfter = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')); results.d1_trust = { sessionStarted: !!regT, projectEntryBefore: trustBefore, projectEntryAfter: claudeJsonAfter?.projects?.[d1] ?? null, note: 'écran du dialogue dans trust.raw.log + steps.jsonl', }; T.kill('SIGTERM'); save(); // ---------- D2 : permission Bash → réponse "1" (Yes) ---------- log('=== D2 permission Bash, approbation ==='); const d2 = freshRepo('bash'); const P = new ClaudeSession([], d2, 'perm-bash', CAPTURES); await P.waitReady(60000); await P.type('Run the bash command `echo spike-s3-marker-xyz` and show me its output. Do not do anything else.'); const regP = await waitWaiting(P); await sleep(1200); // laisse Ink finir de peindre const screenP = P.tail(2500); const optsP = extractOptions(screenP); const respP = await respond(P, '1'); let completed = false; try { await P.waitTurnDone(120000); completed = true; } catch {} results.d2_permBash = { waitingFor: regP.waitingFor ?? null, options: optsP, screenExcerpt: screenP.slice(-1100), respond: respP, turnCompleted: completed, markerInOutput: P.plain().includes('spike-s3-marker-xyz'), }; P.kill('SIGTERM'); save(); // ---------- D3 : permission Write (édition) → réponse "1" ---------- log('=== D3 permission Write ==='); const d3 = freshRepo('write'); const W = new ClaudeSession([], d3, 'perm-write', CAPTURES); await W.waitReady(60000); await W.type('Create a file named s3-edit.txt containing exactly the word hello. Do not do anything else.'); const regW = await waitWaiting(W); await sleep(1200); const screenW = W.tail(2500); const respW = await respond(W, '1'); let completedW = false; try { await W.waitTurnDone(120000); completedW = true; } catch {} results.d3_permWrite = { waitingFor: regW.waitingFor ?? null, options: extractOptions(screenW), screenExcerpt: screenW.slice(-1100), respond: respW, turnCompleted: completedW, fileCreated: existsSync(join(d3, 's3-edit.txt')), }; W.kill('SIGTERM'); save(); // ---------- D4 : refus par Esc ---------- log('=== D4 refus par Esc ==='); const d4 = freshRepo('deny'); const X = new ClaudeSession([], d4, 'deny-esc', CAPTURES); await X.waitReady(60000); await X.type('Run the bash command `echo should-be-denied` and show me its output. Do not do anything else.'); await waitWaiting(X); await sleep(1200); X.write('\x1b'); // Esc await sleep(3500); const regX = X.registry(); results.d4_denyEsc = { statusAfterEsc: regX?.status, denied: !X.plain().includes('should-be-denied\r\nshould-be-denied') /* marqueur non exécuté */, screenTail: X.tail(900), }; X.kill('SIGTERM'); save(); // ---------- D5 : AskUserQuestion ---------- log('=== D5 AskUserQuestion ==='); const d5 = freshRepo('ask'); const Q = new ClaudeSession([], d5, 'ask-question', CAPTURES); await Q.waitReady(60000); await Q.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.'); const regQ = await waitWaiting(Q); await sleep(1200); const screenQ = Q.tail(2500); // Sur AskUserQuestion : essaie flèche bas + Entrée (sélection de la 2e option) Q.write('\x1b[B'); await sleep(400); Q.write('\r'); await sleep(3000); let regQ2 = Q.registry(); const arrowsWorked = regQ2?.status !== 'waiting'; let digitTried = null; if (!arrowsWorked) { Q.write('2'); await sleep(2500); regQ2 = Q.registry(); digitTried = regQ2?.status; } let completedQ = false; try { await Q.waitTurnDone(90000); completedQ = true; } catch {} results.d5_askUserQuestion = { waitingFor: regQ.waitingFor ?? null, options: extractOptions(screenQ), screenExcerpt: screenQ.slice(-1100), arrowsEnterWorked: arrowsWorked, digitFallbackStatus: digitTried, turnCompleted: completedQ, }; Q.kill('SIGTERM'); save(); log('=== S3 dialogues terminé ==='); } catch (err) { results.fatalError = String(err); save(); log('ERREUR FATALE', { err: String(err) }); } finally { save(); process.exit(0); }