Files
arboretum/spikes/s3-tui/run.mjs
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

191 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}