Prototype de pipeline-v2 : ouvrir Suivi, Brief et audit en un geste, choix de fin d'Étape en boutons, proposition de commit générée.

hooks/register.tsx 203 lines1// Actions du Chantier pipeline-v2 : ce qu'un geste dans l'interface peut déclencher.
2//
3// Prototype jetable, sans gestion d'erreurs (Signal de dérive S3). Il répond aux éléments 2, 3 et
4// 11 des constats : proposition de commit générée, ouverture du Suivi, du Brief et de l'audit,
5// choix de fin d'Étape sans tour du modèle.
6//
7// Aucun bouton ne commit ni ne modifie le Suivi : chacun essaie une voie du moteur et l'écrit au
8// journal. Les quatre choix de fin d'Étape essaient chacun une voie différente — soumettre un
9// message, remplir l'invite, lancer une commande, poser une question dans le dialogue du moteur —
10// pour voir laquelle se passe du modèle.
11
12import type { EngineInterface, Register } from 'claude-code'
13
14const IMPL = '.claude/implementation'
15const SLUG = 'pipeline-v2'
16const FICHIER = 'fichier'
17const PROPOSITION = 'proposition'
18
19// Le diff tronqué : assez pour un message de commit, pas de quoi remplir la fenêtre du modèle.
20const DIFF_MAX = 20_000
21
22type Ouvert = { titre: string; chemin: string; texte: string }
23
24let ouvert: Ouvert | undefined
25let proposition = ''
26let choixVisibles = false
27
28const noter = async ($: EngineInterface, texte: string) => {
29 const chemin = `${$.plugin.root}/journal.log`
30 const avant = await $.fs.read(chemin).catch(() => '')
31 await $.fs.write(chemin, `${avant}${new Date(await $.clock.now()).toISOString()} ${texte}\n`)
32}
33
34const ouvrir = async ($: EngineInterface, titre: string, relatif: string) => {
35 const racine = await $.session.root()
36 const chemin = `${racine}/${relatif}`
37 const texte = await $.fs.read(chemin).catch(() => '*absent*')
38 ouvert = { titre, chemin, texte }
39 const place = await $.ui.open({ id: FICHIER, title: titre })
40 await noter($, `/${titre} ${relatif} ui.open ${JSON.stringify(place)}`)
41 $.ui.invalidate('ui.render')
42
43 return { text: `${titre} ouvert.` }
44}
45
46const dernierAudit = async ($: EngineInterface) => {
47 const dossier = `${IMPL}/${SLUG}.audit`
48 const fichiers = await $.fs.list(dossier).catch(() => [])
49 const nom = fichiers
50 .map(entree => entree.name)
51 .sort()
52 .at(-1)
53
54 return nom === undefined ? `${dossier}/aucun-audit` : `${dossier}/${nom}`
55}
56
57export const register: Register = on => {
58 on('session.start', async ($, e, next) => {
59 for (const [name, description] of [
60 ['suivi', 'pipeline-v2 : ouvre le Suivi'],
61 ['brief', 'pipeline-v2 : ouvre le Brief'],
62 ['audit', 'pipeline-v2 : ouvre le dernier audit'],
63 ['fin', 'pipeline-v2 : montre les choix de fin d’Étape'],
64 ['proposer', 'pipeline-v2 : propose un message de commit pour le diff'],
65 ] as const) {
66 await $.command.register({ name, description })
67 }
68
69 return next(e)
70 })
71
72 on('command.run', { command: 'suivi' }, $ => ouvrir($, 'suivi', `${IMPL}/${SLUG}.md`))
73 on('command.run', { command: 'brief' }, $ => ouvrir($, 'brief', `${IMPL}/${SLUG}.brief.md`))
74 on('command.run', { command: 'audit' }, async $ => ouvrir($, 'audit', await dernierAudit($)))
75
76 on('command.run', { command: 'fin' }, async $ => {
77 choixVisibles = true
78 $.ui.invalidate('ui.render')
79 await noter($, '/fin : choix montrés')
80
81 return { text: 'Choix de fin d’Étape montrés au-dessus de l’invite.' }
82 })
83
84 on('command.run', { command: 'proposer' }, async $ => {
85 const racine = await $.session.root()
86 const stat = await $.process.run(['git', 'diff', 'HEAD', '--stat'], { cwd: racine })
87 const diff = await $.process.run(['git', 'diff', 'HEAD'], { cwd: racine })
88 const debut = await $.clock.now()
89 const reponse = await $.model.complete({
90 model: 'haiku',
91 system:
92 'Tu rédiges un message de commit Conventional Commits, en français, à partir d’un diff. Rends le message seul : un titre de 72 caractères au plus, une ligne vide, puis au plus cinq puces.',
93 prompt: `${stat.stdout}\n\n${diff.stdout.slice(0, DIFF_MAX)}`,
94 maxTokens: 400,
95 })
96 const duree = (await $.clock.now()) - debut
97 proposition = reponse.isAnswered ? reponse.text.trim() : `échec : ${reponse.reason}`
98 await noter(
99 $,
100 `/proposer : ${diff.stdout.length} caractères de diff, ${duree} ms, ${reponse.isAnswered ? `${reponse.usage.output_tokens} tokens rendus` : 'pas de réponse'}`,
101 )
102 const place = await $.ui.open({ id: PROPOSITION, title: 'Commit proposé' })
103 await noter($, `ui.open ${JSON.stringify(place)}`)
104 $.ui.invalidate('ui.render')
105
106 return { text: 'Proposition de commit ouverte.' }
107 })
108
109 on('ui.render', { component: 'Pane', requestId: FICHIER }, async ($, e) => {
110 const { Box, Markdown } = $.ui.resolve(e)
111
112 return (
113 <Box flexDirection="column">
114 <Markdown text={`[Ouvrir ${ouvert?.chemin}](file://${ouvert?.chemin})`} />
115 <Markdown text={ouvert?.texte ?? ''} />
116 </Box>
117 )
118 })
119
120 on('ui.render', { component: 'Pane', requestId: PROPOSITION }, async ($, e) => {
121 const { Box, Button, Code } = $.ui.resolve(e)
122
123 return (
124 <Box flexDirection="column">
125 <Code source={proposition} />
126 <Box>
127 <Button
128 key="valider"
129 label="Valider"
130 onPress={async () => {
131 await noter($, `proposition validée (surface ${e.surface}), rien n’est commité`)
132 $.ui.toast('Proposition validée : journalisée, rien n’est commité.')
133 }}
134 />
135 <Button
136 key="refuser"
137 label="Refuser"
138 onPress={async () => {
139 await noter($, `proposition refusée (surface ${e.surface})`)
140 await $.ui.close({ id: PROPOSITION })
141 }}
142 />
143 </Box>
144 </Box>
145 )
146 })
147
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 if (e.props.hasSurvey || !choixVisibles) {
150 return next(e)
151 }
152 const { Box, Button, Text } = $.ui.resolve(e)
153
154 return (
155 <Box flexDirection="column">
156 <Text>Étape terminée — choix (test pv2-actions, rien n’est commité) :</Text>
157 <Box>
158 <Button
159 key="passer"
160 label="1. Valider et passer"
161 onPress={async () => {
162 await noter($, `choix passer (surface ${e.surface}) : $.prompt.submit`)
163 const r = await $.prompt.submit({
164 text: 'Test pv2-actions : bouton « Valider et passer » pressé. Ne fais rien ; réponds seulement « reçu ».',
165 })
166 await noter($, `$.prompt.submit → ${JSON.stringify(r)}`)
167 }}
168 />
169 <Button
170 key="attendre"
171 label="2. Valider et attendre"
172 onPress={async () => {
173 await noter($, `choix attendre (surface ${e.surface}) : $.prompt.fill`)
174 const r = await $.prompt.fill({ text: 'Valider et attendre' })
175 await noter($, `$.prompt.fill → ${JSON.stringify(r)}`)
176 }}
177 />
178 <Button
179 key="suivi"
180 label="3. Voir le Suivi"
181 onPress={async () => {
182 await noter($, `choix suivi (surface ${e.surface}) : $.command.run`)
183 const r = await $.command.run({ command: 'suivi' })
184 await noter($, `$.command.run → ${JSON.stringify(r)}`)
185 }}
186 />
187 <Button
188 key="autre"
189 label="4. Autre chose"
190 onPress={async () => {
191 await noter($, `choix autre (surface ${e.surface}) : $.ui.ask`)
192 const r = await $.ui.ask('Que faire de cette Étape ?', ['Retouche', 'Passation'])
193 await noter($, `$.ui.ask → ${r}`)
194 choixVisibles = false
195 $.ui.invalidate('ui.render')
196 }}
197 />
198 </Box>
199 </Box>
200 )
201 })
202}
203