SLOPSHOPPER

pv2-etat

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

newpanebandcommandtoaststatus
v0.1.0no licenseupdated 2026-10-13NeOzay/.claude/pipeline-v2/proto-etat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pv2-etat
│ ┃ etat ✕ › fix the failing auth test and add an audit log call │ ┃ Étapes — statut undefined, session undefined │ ┃ ⏺ Read(src/auth.ts) │ ┃ Contexte ⎿ Read 6 lines │ ┃ undefined · seuil 300000 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Passation ⏺ Bash(bun test) │ ┃ aucune en attente ⎿ 3 pass, 1 fail │ ┃ │ ┃ Blocages ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ aucun │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Incertitudes du Plan │ ┃ › /etat │ ┃ Dette notée │ ┃ aucune │ ┃ │ ┃ Valeur jetable │ ┃ $.state : 2 (2025-10-09T08:53:20.060Z) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · etat
Étapes — statut undefined, session undefined Contexte undefined · seuil 300000 Passation aucune en attente Blocages aucun Incertitudes du Plan Dette notée aucune Valeur jetable $.state : 2 (2025-10-09T08:53:20.060Z)
Source 2 files
hooks/register.tsx 201 lines
1// É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}
201
types/index.d.ts 14 lines
1/** 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