Harnais d'orchestration du dépôt : rend l'état que mesurent les scripts (suivi de vague, #2279 ; CI et train de publication en status line, #2280), sans porter…

Jeu de rôle vidéoludique 100 % web, en français, type Neverwinter Nights, basé sur les règles de Warhammer Fantasy Roleplay 4ᵉ édition. On contrôle un groupe de 4 aventuriers (créés un par un ou pré-tirés) à travers la campagne impériale L'Ennemi Intérieur.
Toutes les règles et le contenu proviennent des fichiers sources (
Source/). La base de jeu (src/data/*.json) est générée depuis ces sources — limitée au Livre de base et aux Archives de l'Empire I & II — et n'est jamais le fichier source brut.
src/engine) : Tests & Degrés de Réussite, Caractéristiques, Blessures, combat (touche/localisation/dégâts), états, création de personnage. Testé avec Vitest.src/gameIso/rig/), le décor est peint en billboards depuis src/gameIso/catalog/decor/ ; sol, murs, toits et les props volumiques sont bâtis en géométrie par src/gameIso/builders/.src/gameIso/backends/webgl/, la scène elle-même (caméra, lumières, ambiance) par src/gameIso/stage/.npm install
npm run dev # serveur de développement
src/data/*.json sont la source app-owned (commitée, éditée dans le Compendium), curée à la main.
Autres scripts : npm test (suite complète), npm run build (build de production), npm run typecheck.
src/engine/ Règles WFRP4 (pur TS, testé)
src/data/ Base app-owned (JSON commité, éditable dans le Compendium)
src/state/ Schéma de Scène, store Zustand, flux de jet, IA, pathfinding
src/gameIso/ Rendu du monde : builders/ (géométrie pure) montée par stage/ sur le backend
three.js (backends/webgl/) ; rig/ et catalog/ portent l'art SVG ; plus
authoring/, detail/, fx/, pov/
src/geometry/ Grille, projection isométrique, déplacement
src/ui/ Interface React (menus, créateur, HUD, éditeur, compendium)
src/scenes/ Documents de scène et de campagne
src/net/ Coop en ligne : client du relay WS (le Worker Cloudflare vit dans server/)
src/audio/ Musique et sons
src/i18n/ Textes français de l'interface
Le schéma de Scène (src/state/scene.ts) est l'unique contrat partagé par l'éditeur, le runtime et la campagne : aucune scène n'est codée « en dur ».
PR1 pose les fondations et une tranche jouable. Les itérations suivantes ajouteront les Tomes 1-3 complets, la coop en ligne, la magie/les prières en combat, l'économie entre aventures et un bestiaire étendu — la structure data-driven est prévue pour les accueillir sans refonte.
hooks/register.ts 10 lines1// Mod `harnais` (#2278) : un fichier par fonction, chacune enregistrée ici.
2import type { Register } from 'claude-code'
3import { suivi } from './suivi'
4import { vigie } from './vigie'
5
6export const register: Register = (on) => {
7 suivi(on)
8 vigie(on)
9}
10hooks/suivi.ts 171 lines1// Fonction `suivi` du mod `harnais` (#2279) : le suivi de vague `.git/suivi/<N>.json` sous les yeux pendant
2// toute la session ; au démarrage, elle synchronise d'abord le principal (`synchroniser.mjs --json`, #2187).
3// Elle REND l'état de session que calcule le lecteur `scripts/ops/suivi.mjs --session
4// <id> --json [--depuis <cle>]` (`etatDeSession`), enregistre l'outil que décrit `suivi.mjs --outil --json`
5// (son schéma est dérivé de la donnée, #2460) et lui confie chaque lot par `suivi.mjs <N> --session <id>
6// --json --lot <json>` (`editer`). Le lecteur ne mesure jamais : pour chaque épique de son `aMesurer`, la
7// relecture lance `suivi.mjs <N> --mesurer --sans-fetch --json` ; le verrou de mesure de
8// `scripts/ops/suiviMesure.mjs`, pris sans attente, dédoublonne — le mod n'en décide rien (#2460, design §5).
9// Porteurs :
10// https://github.com/MyEdO/game/issues/2278#issuecomment-5983942497
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, On, RenderElement } from 'claude-code'
13import type { HarnaisEtatDeSession, HarnaisOutil, HarnaisSuivi, HarnaisSynchro, HarnaisSynchroEnAttente } from '../types'
14import { appel, lire } from './ops'
15import type { Lu } from './ops'
16
17const DEPART: HarnaisSuivi = { etat: null, cle: null, enAttente: null, generation: '' }
18const atome = atom({ plugin: 'harnais', key: 'suivi' } as const, DEPART)
19
20/**
21 * Période (ms) de relecture. Un lot par l'outil relit aussitôt ; un lot par le CLI (`ops:suivi -- N`) est vu
22 * en une minute au plus, pour un `node` de moins d'une seconde par minute
23 * (#2278, sonde P1). Valeur maison.
24 */
25const PERIODE_MS = 60 * 1000
26
27/** `scripts/ops/suivi.mjs` lancé avec `args` ; un échec va au journal de débogage. */
28async function suiviMjs<T>($: EngineInterface, args: readonly string[]): Promise<Lu<T>> {
29 let lu: Lu<T>
30 try {
31 lu = lire(await $.process.run(...appel($.plugin.root, 'suivi', args)))
32 } catch (erreur) {
33 lu = { ok: false, motif: `non lancé (${String(erreur)})` }
34 }
35 if (!lu.ok) $.ui.log(`harnais, suivi.mjs ${args.join(' ')} : ${lu.motif}`, { to: 'debug' })
36 return lu
37}
38
39/**
40 * Relit l'état de session depuis la clé retenue. Ignorée si une transition qui retient une clé a eu lieu
41 * depuis son lancement ; un échec garde le dernier état valide. Chaque épique de son `aMesurer` est mesurée
42 * (`mesurer`), sans attendre.
43 */
44async function relire($: EngineInterface) {
45 const lancee = await read($, atome)
46 const lu = await suiviMjs<HarnaisEtatDeSession>($, ['--session', await $.session.id(), '--json', ...(lancee.cle ? ['--depuis', lancee.cle] : [])])
47 if (!lu.ok) return
48 await update($, atome, (s) => (s.generation !== lancee.generation ? s : {
49 ...s,
50 etat: lu.valeur,
51 enAttente: lu.valeur.ajout ? { ajout: lu.valeur.ajout, cle: lu.valeur.cle } : s.enAttente,
52 }))
53 for (const epique of lu.valeur.aMesurer) void mesurer($, epique)
54}
55
56/** Borne (ms) d'une mesure : `gh` sur la portée, les branches et les worktrees. Valeur maison. */
57const BORNE_MESURE_MS = 300 * 1000
58
59/**
60 * La mesure de l'épique `epique` (`suivi.mjs <N> --mesurer --sans-fetch --json`) ; la relecture suivante en lit
61 * le résultat. Un échec va au journal de débogage.
62 */
63async function mesurer($: EngineInterface, epique: number) {
64 const args = [String(epique), '--mesurer', '--sans-fetch', '--json']
65 try {
66 const lu = lire(await $.process.run(...appel($.plugin.root, 'suivi', args, { borneMs: BORNE_MESURE_MS })))
67 if (!lu.ok) $.ui.log(`harnais, suivi.mjs ${args.join(' ')} : ${lu.motif}`, { to: 'debug' })
68 } catch (erreur) {
69 $.ui.log(`harnais, suivi.mjs ${args.join(' ')} : non lancé (${String(erreur)})`, { to: 'debug' })
70 }
71}
72
73const atomeSynchro = atom({ plugin: 'harnais', key: 'synchro' } as const, { texte: null } as HarnaisSynchroEnAttente)
74
75/**
76 * Borne (ms) de `synchroniser.mjs` : l'attente de ses verrous, le `fetch`, l'avance git ; le `post-merge`
77 * court en fond (`--consommer`, #2493) ; même valeur que `TIMEOUT_SYNCHRONISEUR`
78 * (`scripts/agents/compat-core.mjs`), côté Codex. Valeur maison.
79 */
80const BORNE_SYNCHRO_MS = 300 * 1000
81
82/** `synchroniser.mjs` lancé avec `args`, son état lu ; un lancement manqué est un échec nommé. */
83async function synchroniserMjs($: EngineInterface, args: readonly string[]): Promise<Lu<HarnaisSynchro>> {
84 try {
85 return lire(await $.process.run(...appel($.plugin.root, 'synchroniser', args, { borneMs: BORNE_SYNCHRO_MS })), { codes: [0, 1, 2] })
86 } catch (erreur) {
87 return { ok: false, motif: `non lancé (${String(erreur)})` }
88 }
89}
90
91/**
92 * Le principal synchronisé (`scripts/ops/synchroniser.mjs --json`, #2187) : son `texte` non vide est retenu
93 * pour UN bloc de contexte ; sans état lisible, le motif et la `ligne` de la re-mesure (`--mesurer --json`, #2493).
94 */
95async function synchroniser($: EngineInterface) {
96 const lu = await synchroniserMjs($, ['--json'])
97 let texte: string | null
98 if (lu.ok) texte = lu.valeur.texte || null
99 else {
100 const mesure = await synchroniserMjs($, ['--mesurer', '--json'])
101 texte = `[synchroniser] principal : synchroniseur sorti sans état (${lu.motif}) ; re-mesure : ${mesure.ok ? mesure.valeur.ligne : mesure.motif}`
102 }
103 await update($, atomeSynchro, () => ({ texte }))
104}
105
106/** La transition qui retient la clé `cle` : l'attente est vidée, une génération neuve est tirée. */
107const retenir = (s: HarnaisSuivi, cle: string, etat: HarnaisEtatDeSession | null = s.etat): HarnaisSuivi =>
108 ({ etat, cle, enAttente: null, generation: crypto.randomUUID() })
109
110export function suivi(on: On) {
111 on('session.start', async ($, e, next) => {
112 await synchroniser($)
113 await relire($)
114 $.clock.every(PERIODE_MS, () => {
115 void relire($)
116 })
117 const outil = await suiviMjs<HarnaisOutil>($, ['--outil', '--json'])
118 if (outil.ok) await $.tool.register(outil.valeur)
119 return next(e)
120 })
121
122 on('tool.call', { tool: 'mcp__harnais__suivi' }, async ($, e) => {
123 const lot = JSON.stringify({ epique: e.epique, mutations: e.mutations })
124 const lu = await suiviMjs<HarnaisEtatDeSession>($, [String(e.epique), '--session', await $.session.id(), '--json', '--lot', lot])
125 if (!lu.ok) return { deny: lu.motif }
126 await update($, atome, (s) => retenir(s, lu.valeur.cle, lu.valeur))
127 return { result: lu.valeur.ajout }
128 })
129
130 on('prompt.context', async ($, e, next) => {
131 const porte: { etat: HarnaisEtatDeSession | null } = { etat: null }
132 await update($, atome, (s) => {
133 porte.etat = s.etat?.contexte ? s.etat : null
134 return porte.etat ? retenir(s, porte.etat.cle) : s
135 })
136 const synchro: { texte: string | null } = { texte: null }
137 await update($, atomeSynchro, (s) => {
138 synchro.texte = s.texte
139 return s.texte ? { texte: null } : s
140 })
141 const { etat } = porte
142 const blocs = [
143 ...(synchro.texte ? [{ name: 'synchroniser', text: synchro.texte }] : []),
144 ...(etat ? [{ name: 'suivi', text: etat.contexte }] : []),
145 ]
146 return blocs.length ? next({ ...e, blocks: [...e.blocks, ...blocs] }) : next(e)
147 })
148
149 on('turn.start', async ($, e, next) => {
150 const lu = await read($, atome)
151 const { enAttente } = lu
152 if (!enAttente) return next(e)
153 try {
154 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: enAttente.ajout }] } })
155 } catch (erreur) {
156 $.ui.log(`harnais, ajout du suivi non fait, reporté au tour suivant : ${String(erreur)}`, { to: 'debug' })
157 return next(e)
158 }
159 await update($, atome, (s) => (s.generation !== lu.generation ? s : retenir(s, enAttente.cle)))
160 return next(e)
161 })
162
163 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164 const { etat } = await read($, atome)
165 if (e.props.hasSurvey || !etat?.suivis.length) return next(e)
166 const { Box, Text } = $.ui.resolve(e)
167 return h(Box, { flexDirection: 'column' }, ...etat.suivis.flatMap((lie) =>
168 lie.lignes.map((ligne, rang) => h(Text, { key: `${lie.epique}-${rang}`, dimColor: true }, ligne)))) as RenderElement
169 })
170}
171hooks/vigie.ts 52 lines1// Fonction `vigie` du mod `harnais` (#2280, V5) : l'état de la CI (`main`, branche de l'arbre) et du train
2// de publication sous les yeux de l'HUMAIN. Elle REND la mesure de `scripts/ops/vigie.mjs --json --arbre
3// <racine> [--depuis <etat>]` : la status line, un toast par transition. Le réveil du modèle n'est pas
4// ici (V1, `ops:ci -- --attendre`). Design : https://github.com/MyEdO/game/issues/2280#issuecomment-5995615185
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, On } from 'claude-code'
7import type { HarnaisMesureDeVigie, HarnaisVigie } from '../types'
8import { appel, lire } from './ops'
9import type { Lu } from './ops'
10
11const DEPART: HarnaisVigie = { etat: null }
12const atome = atom({ plugin: 'harnais', key: 'vigie' } as const, DEPART)
13
14/** Période (ms) d'une mesure (#2280, V5), valeur maison mesurée le 2026-10-05 : un tick coûte 2,4 s (chaud) à 3,5 s (froid), 8 sessions en dépensent au pire ~960 requêtes gh par heure (~19 % du quota de 5 000), et une minute pèse peu devant la médiane d'une course CI (7 min). */
15const PERIODE_MS = 60 * 1000
16
17/**
18 * Une mesure depuis l'état retenu : la ligne va à la status line, chaque transition à un toast, l'état
19 * rendu devient le `--depuis` suivant. Un échec va au journal de débogage et garde la dernière ligne.
20 */
21async function mesurer($: EngineInterface) {
22 const { etat } = await read($, atome)
23 const args = ['--json', '--arbre', await $.session.root(), ...(etat ? ['--depuis', etat] : [])]
24 let lu: Lu<HarnaisMesureDeVigie>
25 try {
26 lu = lire(await $.process.run(...appel($.plugin.root, 'vigie', args)))
27 } catch (erreur) {
28 lu = { ok: false, motif: `non lancé (${String(erreur)})` }
29 }
30 if (!lu.ok) {
31 $.ui.log(`harnais, vigie.mjs ${args.join(' ')} : ${lu.motif}`, { to: 'debug' })
32 return
33 }
34 const mesure = lu.valeur
35 await update($, atome, () => ({ etat: mesure.etat }))
36 $.ui.status(mesure.ligne)
37 for (const transition of mesure.transitions) $.ui.toast(transition)
38}
39
40export function vigie(on: On) {
41 // Matcher `isInteractive` : le moteur 2.1.289 refuse deux `session.start` sans matcher dans un plugin
42 // (`claude plugin validate --strict`, #2280), et la fonction rend pour l'humain ; une surface non
43 // interactive (`-p`, SDK, Desktop s'il passe par le SDK) ne lance pas la vigie.
44 on('session.start', { isInteractive: true }, async ($, e, next) => {
45 void mesurer($)
46 $.clock.every(PERIODE_MS, () => {
47 void mesurer($)
48 })
49 return next(e)
50 })
51}
52hooks/ops.ts 36 lines1// COUTURE du mod `harnais` (#2278), PURE : l'appel d'un script du dépôt et la lecture de sa sortie JSON
2// (mur `murs/mod-sans-regle`, `VERROU_MOD_COUTURE` d'oxlint.config.mjs). Le moteur ne suit `$` dans aucun import : le lancement
3// s'écrit au site d'appel, `lire(await $.process.run(...appel($.plugin.root, <script>, <args>)))`.
4import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
5
6/** Borne (ms) par défaut d'un script lancé ; le lecteur du suivi répond en moins d'une seconde (#2278, sonde P1). Valeur maison. */
7const BORNE_MS = 20 * 1000
8
9/** Ce que rend `lire` : la valeur lue, ou le motif de l'échec. */
10export type Lu<T> = { ok: true; valeur: T } | { ok: false; motif: string }
11
12/**
13 * L'argv et les options de `$.process.run` pour `node <dépôt>/scripts/ops/<script>.mjs ...args`, lancé depuis
14 * la racine du dépôt, trois niveaux au-dessus de `racinePlugin` (`.claude/skills/<mod>`), borné à `borneMs`.
15 */
16export function appel(racinePlugin: string, script: string, args: readonly string[], { borneMs = BORNE_MS }: { borneMs?: number } = {}): readonly [string[], ProcessRunInit] {
17 const racine = `${racinePlugin}/../../..`
18 return [['node', `${racine}/scripts/ops/${script}.mjs`, ...args], { cwd: racine, timeoutMs: borneMs }]
19}
20
21/**
22 * La sortie JSON d'un script : un objet, sinon le motif (code hors de `codes`, JSON illisible, forme fausse).
23 * `codes` : les codes de sortie dont le script écrit son JSON (0 par défaut).
24 */
25export function lire<T>(resultat: ProcessRunResult, { codes = [0] }: { codes?: readonly number[] } = {}): Lu<T> {
26 if (!codes.some((code) => code === resultat.exitCode)) return { ok: false, motif: resultat.stderr.trim() || `code de sortie ${resultat.exitCode}` }
27 let valeur: unknown
28 try {
29 valeur = JSON.parse(resultat.stdout)
30 } catch {
31 return { ok: false, motif: 'sortie JSON illisible' }
32 }
33 if (typeof valeur !== 'object' || valeur === null || Array.isArray(valeur)) return { ok: false, motif: 'sortie JSON qui n’est pas un objet' }
34 return { ok: true, valeur: valeur as T }
35}
36types/index.d.ts 64 lines1// Types du mod `harnais` : fonction `suivi` (#2279), l'état de session que rend `node scripts/ops/suivi.mjs
2// --session <id> --json` (`etatDeSession`, scripts/ops/suivi.mjs), l'outil que rend `--outil --json` et l'atome
3// `harnais.suivi` ; fonction `vigie`
4// (#2280), la mesure que rend `node scripts/ops/vigie.mjs --json --arbre <racine>` et l'atome `harnais.vigie` ;
5// l'état que rend `node scripts/ops/synchroniser.mjs --json` (#2187) et l'atome `harnais.synchro`.
6
7/** Un suivi lié à la session : son épique, son chemin et les lignes de sa situation (le bandeau). */
8export type HarnaisSuiviLie = { epique: number; chemin: string; lignes: string[] }
9
10/** L'état d'une session (`etatDeSession`), prêt à rendre. */
11export type HarnaisEtatDeSession = {
12 session: string
13 suivis: HarnaisSuiviLie[]
14 contexte: string
15 ajout: string
16 cle: string
17 /** Les épiques dont la confrontation demande une mesure (absente, illisible, périmée, portée changée). */
18 aMesurer: number[]
19}
20
21/** L'outil MCP que décrit `suivi.mjs --outil --json` (`OUTIL_SUIVI`, scripts/ops/suiviDonnee.mjs) : son schéma est dérivé de la donnée. */
22export type HarnaisOutil = { name: string; description: string; inputSchema: Record<string, unknown> }
23
24/** Une situation datée rendue par le lecteur, en attente du prochain tour, et la clé qu'elle porte. */
25export type HarnaisAjout = { ajout: string; cle: string }
26
27/**
28 * L'atome UNIQUE de la fonction `suivi`, modifié par une seule `update` à chaque transition :
29 * - `etat` : le dernier état de session VALIDE lu (`null` avant toute lecture réussie) ;
30 * - `cle` : la clé du dernier état porté en contexte, ajouté ou rendu par l'outil, le `--depuis` du lecteur ;
31 * - `enAttente` : la situation datée que le lecteur a rendue depuis `cle`, en attente du prochain tour ;
32 * - `generation` : le jeton, tiré neuf, de la dernière transition qui a retenu une clé ; une relecture
33 * lancée sous une autre génération est ignorée.
34 */
35export type HarnaisSuivi = {
36 etat: HarnaisEtatDeSession | null
37 cle: string | null
38 enAttente: HarnaisAjout | null
39 generation: string
40}
41
42/** La mesure de `vigie.mjs --json` : la status line, une phrase de toast par transition, l'état opaque à repasser en `--depuis`. */
43export type HarnaisMesureDeVigie = { ligne: string; transitions: string[]; etat: string }
44
45/** L'atome de la fonction `vigie` : l'`etat` de la dernière mesure VALIDE (`null` avant la première), le `--depuis` suivant. */
46export type HarnaisVigie = { etat: string | null }
47
48/** L'état que rend `synchroniser.mjs --json` ou `--mesurer --json` (#2187, #2493) : son `etat` nommé, son `texte`
49 * de session (vide pour un état muet) et ses champs, opaques au mod. */
50export type HarnaisSynchro = { etat: string, texte: string, ligne?: string } & Record<string, unknown>
51
52/** L'atome `harnais.synchro` : le texte de l'état de synchronisation à porter UNE fois en contexte, `null` sinon. */
53export type HarnaisSynchroEnAttente = { texte: string | null }
54
55declare module 'claude-code' {
56 interface PluginState {
57 harnais: {
58 suivi: HarnaisSuivi
59 vigie: HarnaisVigie
60 synchro: HarnaisSynchroEnAttente
61 }
62 }
63}
64