Prototype de pipeline-v2 : affiche l'état du Chantier lu dans son Suivi (Étapes, contexte, Passation, blocages, incertitudes, dette).

hooks/register.tsx 201 lines1// État du Chantier pipeline-v2, lu dans son Suivi et montré dans l'interface.
2//
3// Prototype jetable, sans gestion d'erreurs (Signal de dérive S3). Il répond aux éléments 1, 4, 6,
4// 8, 9 et 10 des constats : Étapes, contexte et seuil de Passation, Passation en attente,
5// blocages, incertitudes du Plan, dette notée. Le Suivi reste la source : le mod le relit au
6// démarrage, à `/etat` et à chaque fin de tour, et garde ce qu'il en tire dans une variable du
7// module, qu'un rechargement remet à zéro.
8//
9// `$.state` et `$.store` ne reçoivent qu'une valeur jetable, un compteur et son horodatage
10// (décision Q7 du Plan) : `journal.log` note ce qui en survit à `/clear` et au redémarrage.
11
12import type { EngineInterface, Register } from 'claude-code'
13
14const SUIVI = '.claude/implementation/pipeline-v2.md'
15const SLUG = 'pipeline-v2'
16const PANNEAU = 'etat'
17
18// Le seuil de Passation de la v1 (references/execution.md) : au-delà, la Passation se propose.
19const SEUIL = 300_000
20
21const COMPTEUR = { plugin: 'pv2-etat', key: 'compteur' } as const
22const HORODATAGE = { plugin: 'pv2-etat', key: 'horodatage' } as const
23
24type Etape = { marque: string; numero: string; titre: string }
25
26type Etat = {
27 etapes: Etape[]
28 statut: string
29 session: string
30 passation: string | undefined
31 incertitudes: string[]
32 dette: string
33 contexte: string
34}
35
36let etat: Etat | undefined
37
38// `$.fs.write` réécrit le fichier entier : relire avant d'ajouter, pour que le journal garde
39// d'une session à l'autre ce que `$.state` et `$.store` y ont laissé.
40const noter = async ($: EngineInterface, texte: string) => {
41 const chemin = `${$.plugin.root}/journal.log`
42 const avant = await $.fs.read(chemin).catch(() => '')
43 await $.fs.write(chemin, `${avant}${new Date(await $.clock.now()).toISOString()} ${texte}\n`)
44}
45
46const champ = (texte: string, nom: string) =>
47 texte.match(new RegExp(`^"?${nom}"? = "?([^"\\n]*)"?$`, 'm'))?.[1] ?? ''
48
49const section = (texte: string, titre: string) =>
50 texte
51 .split(/^## /m)
52 .find(bloc => bloc.startsWith(titre))
53 ?.slice(titre.length)
54 .trim() ?? ''
55
56const lire = async ($: EngineInterface): Promise<Etat> => {
57 const racine = await $.session.root()
58 const suivi = await $.fs.read(`${racine}/${SUIVI}`)
59 const plan = await $.fs.read(`${racine}/${champ(suivi, 'plan')}`)
60 const dernier = await $.process.run(['git', 'log', '-1', '--format=%s'], { cwd: racine })
61 const contexte = await $.process.run(['contexte'], { cwd: racine }).catch(() => undefined)
62 const usage = await $.session.usage()
63 const moteur = `${usage.context.tokens ?? '?'} / ${usage.context.window} (${usage.context.percent ?? '?'} %)`
64
65 return {
66 etapes: [...section(suivi, 'Étapes').matchAll(/^- \[(.)\] (\d+)\. (.+?) —/gm)].map(m => ({
67 marque: m[1] ?? '',
68 numero: m[2] ?? '',
69 titre: m[3] ?? '',
70 })),
71 statut: champ(suivi, 'statut'),
72 session: champ(suivi, 'session'),
73 passation: dernier.stdout.startsWith(`${SLUG}: passation`) ? dernier.stdout.trim() : undefined,
74 incertitudes: (plan.match(/^INCERTITUDES :\n([\s\S]*?)\n[A-ZÀ-Ü]+ :/m)?.[1] ?? '')
75 .split('\n')
76 .filter(Boolean),
77 dette: section(suivi, 'Dettes écartées'),
78 contexte: `commande contexte : ${contexte?.exitCode === 0 ? contexte.stdout.trim() : `indisponible (${contexte?.stderr.trim() ?? 'introuvable'})`} · moteur : ${moteur}`,
79 }
80}
81
82const rafraichir = async ($: EngineInterface) => {
83 etat = await lire($)
84 const tokens = Number(etat.contexte.match(/contexte : (\d+)/)?.[1] ?? Number.NaN)
85 $.ui.status(
86 Number.isNaN(tokens)
87 ? `contexte ? / seuil ${SEUIL}`
88 : `contexte ${tokens} / seuil ${SEUIL}${tokens > SEUIL ? ' : Passation' : ''}`,
89 )
90 $.ui.invalidate('ui.render')
91}
92
93const compter = async ($: EngineInterface, quand: string) => {
94 const avantEtat = await $.state.get(COMPTEUR)
95 const avantHeure = await $.state.get(HORODATAGE)
96 const avantStore = await $.store.get('compteur')
97 const heure = new Date(await $.clock.now()).toISOString()
98 await $.state.set(COMPTEUR, (avantEtat.value ?? 0) + 1)
99 await $.state.set(HORODATAGE, heure)
100 await $.store.set('compteur', Number(avantStore ?? 0) + 1)
101 await $.store.set('horodatage', heure)
102 await noter(
103 $,
104 `${quand} : $.state trouvé ${avantEtat.value ?? 'rien'} (${avantHeure.value ?? 'rien'}), $.store trouvé ${String(avantStore ?? 'rien')}`,
105 )
106}
107
108const marque = (m: string) => ({ x: 'fait', '>': 'en cours', '!': 'bloqué' })[m] ?? 'à venir'
109
110export const register: Register = on => {
111 on('session.attach', async ($, e, next) => {
112 await noter($, `session.attach surface=${e.surface}`)
113
114 return next(e)
115 })
116
117 on('session.start', async ($, e, next) => {
118 await $.command.register({ name: 'etat', description: 'pipeline-v2 : état du Chantier' })
119 await compter($, 'session.start')
120 await rafraichir($)
121 if (etat?.passation !== undefined) {
122 $.ui.toast(`Passation à lire : ${etat.passation}`, { timeoutMs: 15000 })
123 }
124
125 return next(e)
126 })
127
128 on('turn.complete', async ($, e, next) => {
129 await rafraichir($)
130 await noter($, `turn.complete ${etat?.contexte}`)
131
132 return next(e)
133 })
134
135 on('command.run', { command: 'etat' }, async $ => {
136 await compter($, '/etat')
137 await rafraichir($)
138 const ouvert = await $.ui.open({ id: PANNEAU, title: 'Chantier pipeline-v2' })
139 await noter($, `ui.open ${JSON.stringify(ouvert)}`)
140
141 return { text: 'État du Chantier ouvert.' }
142 })
143
144 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145 if (e.props.hasSurvey || etat === undefined) {
146 return next(e)
147 }
148 const { Box, Text } = $.ui.resolve(e)
149 const faites = etat.etapes.filter(etape => etape.marque === 'x').length
150 const courante = etat.etapes.find(etape => etape.marque === '>' || etape.marque === '!')
151
152 return (
153 <Box flexDirection="column">
154 <Text>
155 {SLUG} · {faites}/{etat.etapes.length} Étapes · {etat.statut}
156 {courante ? ` · Étape ${courante.numero} ${marque(courante.marque)}` : ''}
157 </Text>
158 {etat.passation !== undefined && <Text>Passation à lire : {etat.passation}</Text>}
159 </Box>
160 )
161 })
162
163 on('ui.render', { component: 'Pane', requestId: PANNEAU }, async ($, e) => {
164 const { Markdown } = $.ui.resolve(e)
165 const compteur = await $.state.get(COMPTEUR)
166 const horodatage = await $.state.get(HORODATAGE)
167 const blocages = etat?.etapes.filter(etape => etape.marque === '!') ?? []
168
169 return (
170 <Markdown
171 text={[
172 `## Étapes — statut \`${etat?.statut}\`, session ${etat?.session}`,
173 ...(etat?.etapes ?? []).map(
174 etape => `- **${etape.numero}.** ${etape.titre} — *${marque(etape.marque)}*`,
175 ),
176 '',
177 `## Contexte`,
178 `${etat?.contexte} · seuil ${SEUIL}`,
179 '',
180 `## Passation`,
181 etat?.passation ?? 'aucune en attente',
182 '',
183 `## Blocages`,
184 blocages.length === 0
185 ? 'aucun'
186 : blocages.map(etape => `- ${etape.numero}. ${etape.titre}`).join('\n'),
187 '',
188 `## Incertitudes du Plan`,
189 ...(etat?.incertitudes ?? []).map(ligne => `- ${ligne}`),
190 '',
191 `## Dette notée`,
192 etat?.dette || 'aucune',
193 '',
194 `## Valeur jetable`,
195 `$.state : ${compteur.value ?? 'rien'} (${horodatage.value ?? 'rien'})`,
196 ].join('\n')}
197 />
198 )
199 })
200}
201types/index.d.ts 14 lines1/** Valeur jetable : quand la session a écrit le compteur, en ISO 8601. */
2export type Horodatage = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 'pv2-etat': {
7 /** Valeur jetable : combien de fois la session a écrit, pour voir ce qui en survit. */
8 compteur: number
9 /** Valeur jetable : quand la session a écrit le compteur. */
10 horodatage: Horodatage
11 }
12 }
13}
14