Prototype de pipeline-v2 : surveille les fichiers touchés, les Signaux de dérive et les sous-agents d'un Chantier.

hooks/register.tsx 160 lines1// Veille du Chantier pipeline-v2 : ce que des hooks voient pendant que le modèle travaille.
2//
3// Prototype jetable, sans gestion d'erreurs (Signal de dérive S3). Il répond aux éléments 5, 7 et
4// 12 des constats : fichiers touchés face aux fichiers de l'Étape en cours, état et verdict des
5// sous-agents, Signaux de dérive vérifiables par commande.
6//
7// Les fichiers prévus sont ceux de la ligne de l'Étape `[>]` du Suivi, entre accents graves, avant
8// « vérif ». Les Signaux se relancent après chaque `Edit` ou `Write`, chacun par sa commande du
9// Brief ; un toast dit celui qui tombe. Un sous-agent se suit par l'appel de l'outil `Agent` :
10// lancé avant `next`, terminé quand `next` rend, son verdict lu dans sa réponse.
11//
12// `/lever` fait lever la bande, `/lever-commande` fait lever la commande elle-même : ce que le
13// moteur montre d'un hook qui échoue est la matière de la politique d'échec.
14
15import type { EngineInterface, Register } from 'claude-code'
16
17const SUIVI = '.claude/implementation/pipeline-v2.md'
18
19// Les Signaux de dérive du Brief qu'une commande vérifie : elle sort 0 quand le Signal tombe.
20const SIGNAUX = {
21 S1: 'test -n "$(git diff master --stat -- skills/implementation-tracker skills/intent-brief skills/git-smart-commit)"',
22 S2: 'test -n "$(git diff master --name-only -- skills .claude/skills settings.json)" || grep -q CLAUDE_CODE_PLUGIN_DIRS settings.json',
23 S6: 'test -n "$(git diff master -- LEXIQUE.md)"',
24} as const
25
26type Agent = { id: string; type: string; etat: string; verdict: string }
27
28let prevus: string[] = []
29let etape = ''
30const horsPrevus = new Set<string>()
31let tombes: string[] = []
32const agents: Agent[] = []
33let leve = false
34
35const noter = async ($: EngineInterface, texte: string) => {
36 const chemin = `${$.plugin.root}/journal.log`
37 const avant = await $.fs.read(chemin).catch(() => '')
38 await $.fs.write(chemin, `${avant}${new Date(await $.clock.now()).toISOString()} ${texte}\n`)
39}
40
41const lirePrevus = async ($: EngineInterface) => {
42 const suivi = await $.fs.read(`${await $.session.root()}/${SUIVI}`)
43 const ligne = suivi.match(/^- \[>\] (\d+)\. ([\s\S]*?)— vérif/m)
44 etape = ligne?.[1] ?? '?'
45 prevus = [...(ligne?.[2] ?? '').matchAll(/`([^`]+)`/g)].map(m => m[1] ?? '')
46}
47
48const verifier = async ($: EngineInterface) => {
49 const racine = await $.session.root()
50 const avant = tombes
51 tombes = []
52 for (const [nom, commande] of Object.entries(SIGNAUX)) {
53 const r = await $.process.run(['bash', '-c', commande], { cwd: racine })
54 if (r.exitCode === 0) {
55 tombes.push(nom)
56 }
57 }
58 const nouveaux = tombes.filter(nom => !avant.includes(nom))
59 if (nouveaux.length > 0) {
60 $.ui.toast(`Signal de dérive : ${nouveaux.join(', ')}`, { timeoutMs: 15000 })
61 }
62 await noter($, `signaux tombés : ${tombes.join(', ') || 'aucun'}`)
63 $.ui.invalidate('ui.render')
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await $.command.register({ name: 'lever', description: 'pv2-veille : fait lever la bande' })
69 await $.command.register({
70 name: 'lever-commande',
71 description: 'pv2-veille : fait lever cette commande',
72 })
73 await lirePrevus($)
74 await noter($, `session.start : Étape ${etape}, prévus ${JSON.stringify(prevus)}`)
75 await verifier($)
76
77 return next(e)
78 })
79
80 on('tool.call', async ($, e, next) => {
81 const ran = await next(e)
82 if (e.tool !== 'Edit' && e.tool !== 'Write') {
83 return ran
84 }
85 const racine = await $.session.root()
86 const relatif = e.file_path.startsWith(`${racine}/`)
87 ? e.file_path.slice(racine.length + 1)
88 : e.file_path
89 const estPrevu = prevus.some(prevu => relatif === prevu || relatif.startsWith(prevu))
90 if (!estPrevu) {
91 horsPrevus.add(relatif)
92 }
93 await noter($, `${e.tool} ${relatif} : ${estPrevu ? 'prévu' : 'hors prévus'}`)
94 await verifier($)
95
96 return ran
97 })
98
99 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
100 const agent: Agent = {
101 id: e.tool_use_id,
102 type: e.subagent_type ?? 'general-purpose',
103 etat: 'en cours',
104 verdict: '',
105 }
106 agents.push(agent)
107 await noter($, `Agent lancé : ${agent.type} (${e.description})`)
108 $.ui.invalidate('ui.render')
109 const ran = await next(e)
110 const texte = ran.text ?? JSON.stringify(ran.result ?? '')
111 agent.etat = ran.deny !== undefined ? 'refusé' : ran.isError === true ? 'en erreur' : 'terminé'
112 agent.verdict = texte.match(/VERDICT\s*:\s*([A-ZÉ ]+)/)?.[1]?.trim() ?? ''
113 await noter(
114 $,
115 `Agent ${agent.etat} : ${agent.type}, verdict « ${agent.verdict || 'aucun'} », ${texte.length} caractères`,
116 )
117 $.ui.invalidate('ui.render')
118
119 return ran
120 })
121
122 on('command.run', { command: 'lever' }, async () => {
123 leve = true
124
125 return { text: 'La bande va lever à son prochain dessin.' }
126 })
127
128 on('command.run', { command: 'lever-commande' }, async () => {
129 throw new Error('pv2-veille : erreur voulue dans command.run')
130 })
131
132 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133 if (leve) {
134 throw new Error('pv2-veille : erreur voulue dans ui.render')
135 }
136 if (e.props.hasSurvey) {
137 return next(e)
138 }
139 const { Box, Text } = $.ui.resolve(e)
140 const dernier = agents.at(-1)
141
142 return (
143 <Box flexDirection="column">
144 <Text>
145 Étape {etape} · prévus : {prevus.join(', ') || 'aucun'}
146 {horsPrevus.size > 0 ? ` · hors prévus : ${[...horsPrevus].join(', ')}` : ''}
147 </Text>
148 <Text>Signaux : {tombes.length === 0 ? 'aucun tombé' : `tombés ${tombes.join(', ')}`}</Text>
149 {dernier && (
150 <Text>
151 Agents : {agents.filter(agent => agent.etat === 'en cours').length} en cours · dernier{' '}
152 {dernier.type} {dernier.etat}
153 {dernier.verdict ? ` — ${dernier.verdict}` : ''}
154 </Text>
155 )}
156 </Box>
157 )
158 })
159}
160