@roomi-fields/grillhouse 0.0.0-stage → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/METACADRE.md +41 -0
  3. package/agents/developpeur.md +10 -0
  4. package/agents/explorateur.md +9 -0
  5. package/agents/questionneur.md +11 -0
  6. package/agents/referent-technique.md +9 -0
  7. package/agents/regulateur.md +11 -0
  8. package/agents/relecteur.md +9 -0
  9. package/agents/testwriter.md +10 -0
  10. package/bin/grillhouse.mjs +125 -0
  11. package/cadre/templates/projet/.claude/settings.json +30 -0
  12. package/cadre/templates/projet/.editorconfig +47 -0
  13. package/cadre/templates/projet/.env.example +57 -0
  14. package/cadre/templates/projet/.eslintrc.cjs +56 -0
  15. package/cadre/templates/projet/.github/workflows/ci.yml +66 -0
  16. package/cadre/templates/projet/.mcp.json +13 -0
  17. package/cadre/templates/projet/.prettierrc +11 -0
  18. package/cadre/templates/projet/CHANGELOG.md +61 -0
  19. package/cadre/templates/projet/CLAUDE.md +46 -0
  20. package/cadre/templates/projet/CONTRIBUTING.md +243 -0
  21. package/cadre/templates/projet/CREDITS.md +41 -0
  22. package/cadre/templates/projet/README.md +152 -0
  23. package/cadre/templates/projet/_gitignore +91 -0
  24. package/cadre/templates/projet/_npmignore +46 -0
  25. package/cadre/templates/projet/deployment/README.md +98 -0
  26. package/cadre/templates/projet/deployment/docs/01-INSTALL.md +145 -0
  27. package/cadre/templates/projet/deployment/docs/02-CONFIGURATION.md +117 -0
  28. package/cadre/templates/projet/deployment/docs/03-API.md +264 -0
  29. package/cadre/templates/projet/deployment/docs/04-TROUBLESHOOTING.md +262 -0
  30. package/cadre/templates/projet/deployment/scripts/install.ps1 +100 -0
  31. package/cadre/templates/projet/deployment/scripts/start.ps1 +61 -0
  32. package/cadre/templates/projet/deployment/scripts/stop.ps1 +37 -0
  33. package/cadre/templates/projet/deployment/scripts/test.ps1 +118 -0
  34. package/cadre/templates/projet/docs/agents/issue-tracker.md +44 -0
  35. package/cadre/templates/projet/ecosystem.config.cjs +73 -0
  36. package/cadre/templates/projet/package.json +90 -0
  37. package/cadre/templates/projet/pitmaster/SUIVI.md +22 -0
  38. package/cadre/templates/projet/src/config.ts +68 -0
  39. package/cadre/templates/projet/src/errors.ts +102 -0
  40. package/cadre/templates/projet/src/index.ts +46 -0
  41. package/cadre/templates/projet/src/services/.gitkeep +8 -0
  42. package/cadre/templates/projet/src/types/index.ts +24 -0
  43. package/cadre/templates/projet/src/utils/logger.ts +106 -0
  44. package/cadre/templates/projet/tests/integration/.gitkeep +7 -0
  45. package/cadre/templates/projet/tests/unit/config.spec.ts +68 -0
  46. package/cadre/templates/projet/tests/unit/errors.spec.ts +132 -0
  47. package/cadre/templates/projet/tests/unit/types.spec.ts +73 -0
  48. package/cadre/templates/projet/tsconfig.eslint.json +9 -0
  49. package/cadre/templates/projet/tsconfig.json +41 -0
  50. package/cadre/templates/projet/tsdoc.json +6 -0
  51. package/cadre/templates/projet/vitest.config.ts +44 -0
  52. package/cadre/templates/turbo/paquets.json +29 -0
  53. package/cadre/templates/turbo/sans-paquets.json +28 -0
  54. package/charte.md +107 -0
  55. package/docs/ARCHITECTURE.md +90 -0
  56. package/hooks/crochet.mjs +33 -0
  57. package/hooks/hooks.json +58 -0
  58. package/hooks/register.tsx +558 -0
  59. package/package.json +75 -4
  60. package/scripts/avancement.mjs +244 -0
  61. package/scripts/bilan.mjs +523 -0
  62. package/scripts/branche-de-nuit.mjs +59 -0
  63. package/scripts/cadre.mjs +109 -0
  64. package/scripts/circuit.mjs +4375 -0
  65. package/scripts/code-mort.mjs +371 -0
  66. package/scripts/composants-affectes.mjs +598 -0
  67. package/scripts/consommateurs-arbitres.mjs +192 -0
  68. package/scripts/consommateurs.mjs +310 -0
  69. package/scripts/crochet.mjs +24 -0
  70. package/scripts/crochets.mjs +135 -0
  71. package/scripts/dernier-vert.mjs +208 -0
  72. package/scripts/dette.mjs +181 -0
  73. package/scripts/droits.mjs +354 -0
  74. package/scripts/enveloppe/codes.mjs +17 -0
  75. package/scripts/enveloppe/composant.mjs +11 -0
  76. package/scripts/enveloppe/enveloppe.mjs +628 -0
  77. package/scripts/enveloppe/lancer.sh +311 -0
  78. package/scripts/etapes.mjs +323 -0
  79. package/scripts/etat-du-circuit.mjs +104 -0
  80. package/scripts/frontieres.mjs +456 -0
  81. package/scripts/gardes-du-cadre.mjs +72 -0
  82. package/scripts/greffons.mjs +338 -0
  83. package/scripts/grillhouse-maj.mjs +514 -0
  84. package/scripts/index-interfaces.mjs +293 -0
  85. package/scripts/init.mjs +117 -0
  86. package/scripts/integration/file-attente.mjs +61 -0
  87. package/scripts/integration/file.mjs +494 -0
  88. package/scripts/integration/integrer.mjs +381 -0
  89. package/scripts/integration/rejeu.mjs +139 -0
  90. package/scripts/integration/verdict.mjs +13 -0
  91. package/scripts/interfaces-contre-code.mjs +547 -0
  92. package/scripts/ligne-etat/defaut.sh +110 -0
  93. package/scripts/ligne-etat/ligne-etat.mjs +93 -0
  94. package/scripts/migrations/0.23.0-charte-importee.mjs +47 -0
  95. package/scripts/migrations/0.23.0-config-vitest.mjs +105 -0
  96. package/scripts/migrations/0.23.0-crochets-de-seance.mjs +55 -0
  97. package/scripts/migrations/0.23.0-marche-du-projet.mjs +82 -0
  98. package/scripts/migrations.mjs +43 -0
  99. package/scripts/nuit.sh +269 -0
  100. package/scripts/paquets.mjs +79 -0
  101. package/scripts/poussee-cles.mjs +46 -0
  102. package/scripts/preparation.mjs +84 -0
  103. package/scripts/references-par-les-types.mjs +420 -0
  104. package/scripts/registre.mjs +336 -0
  105. package/scripts/resultats.mjs +580 -0
  106. package/scripts/resumes.mjs +57 -0
  107. package/scripts/retirer-copie.sh +127 -0
  108. package/scripts/session-start.sh +203 -0
  109. package/scripts/sortie.mjs +155 -0
  110. package/scripts/sous-modules.sh +316 -0
  111. package/scripts/structure-guard.sh +27 -0
  112. package/scripts/superviseur.mjs +102 -0
  113. package/scripts/tableau.mjs +1886 -0
  114. package/scripts/tests.mjs +236 -0
  115. package/scripts/ticket.sh +23 -0
  116. package/scripts/turbo-des-paquets.mjs +899 -0
  117. package/scripts/un-commit-par-ticket.mjs +212 -0
  118. package/scripts/verifier.mjs +469 -0
  119. package/scripts/verrou-de-fichier.mjs +130 -0
  120. package/scripts/verrou-de-machine.mjs +211 -0
  121. package/scripts/verrous/commande.mjs +180 -0
  122. package/scripts/verrous/ticket-du-sous-agent.mjs +39 -0
  123. package/scripts/verrous/verrou.mjs +165 -0
  124. package/scripts/vitest-config/avancement.mjs +39 -0
  125. package/scripts/vitest-config/avancement.reporter.mjs +56 -0
  126. package/scripts/vitest-config/base-de-passe.mjs +128 -0
  127. package/scripts/vitest-config/base.d.ts +6 -0
  128. package/scripts/vitest-config/base.mjs +87 -0
  129. package/scripts/vitest-config/changements.mjs +173 -0
  130. package/scripts/vitest-config/cibles.mjs +207 -0
  131. package/scripts/vitest-config/duree.reporter.mjs +98 -0
  132. package/scripts/vitest-config/machine.setup.mjs +14 -0
  133. package/scripts/vitest-config/mesure.d.ts +6 -0
  134. package/scripts/vitest-config/mesure.mjs +13 -0
  135. package/scripts/vitest-config/mesure.setup.mjs +12 -0
  136. package/scripts/vitest-config/pression.mjs +90 -0
  137. package/scripts/vitest-config/resultats.mjs +23 -0
  138. package/scripts/vitest-config/resultats.setup.mjs +19 -0
  139. package/scripts/vitest.nuit.config.mjs +26 -0
  140. package/skills/developpeur/SKILL.md +175 -0
  141. package/skills/explorateur/SKILL.md +84 -0
  142. package/skills/grill/SKILL.md +238 -0
  143. package/skills/grill/references/releve-structurel.md +38 -0
  144. package/skills/mesure/SKILL.md +168 -0
  145. package/skills/pitmaster/SKILL.md +382 -0
  146. package/skills/pitmaster/references/cadre.md +119 -0
  147. package/skills/pitmaster/references/consigne-agent.md +308 -0
  148. package/skills/pitmaster/references/erreurs.md +25 -0
  149. package/skills/pitmaster/references/tour.md +124 -0
  150. package/skills/questionneur/SKILL.md +64 -0
  151. package/skills/redacteur/SKILL.md +84 -0
  152. package/skills/redacteur/references/documents-d-un-composant.md +105 -0
  153. package/skills/referent-technique/SKILL.md +145 -0
  154. package/skills/regulateur/SKILL.md +220 -0
  155. package/skills/release/SKILL.md +134 -0
  156. package/skills/release/scripts/update-version.cjs +92 -0
  157. package/skills/relecteur/SKILL.md +152 -0
  158. package/skills/relecteur/references/LICENSE-MIT +30 -0
  159. package/skills/relecteur/references/SOURCE.md +8 -0
  160. package/skills/relecteur/references/claims-check.md +14 -0
  161. package/skills/relecteur/references/deletion-check.md +14 -0
  162. package/skills/relecteur/references/edge-case-hunter.md +80 -0
  163. package/skills/relecteur/references/verification-gap.md +113 -0
  164. package/skills/testwriter/SKILL.md +187 -0
  165. package/types/index.d.ts +56 -0
  166. package/README.md +0 -3
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env node
2
+ // The bilan of a ticket: its figures first, as a blameless post-mortem of Google SRE opens, measured
3
+ // as BuildBuddy measures a build from its events, its wall time a lead time of DORA. The circuit
4
+ // (scripts/circuit.mjs) writes it at the ticket at each of its ends; the board shows its key
5
+ // figures on the ticket's card.
6
+ //
7
+ // npx grillhouse bilan <ticket> [--garder]
8
+ //
9
+ // prints the bilan of <ticket>, read from the main tree of the current directory; `--garder` keeps
10
+ // it too, as the circuit does, so the next ticket's bilan compares itself with it.
11
+ //
12
+ // - Its sources are structured results, never a text (tests/unit/circuit-sans-texte-libre.spec.ts):
13
+ // the register of finished agents (`~/.claude/grillhouse/registre.jsonl`, GRILLHOUSE_REGISTRE
14
+ // replaces it), each agent's latest line on the ticket (its project the main tree, or a folder
15
+ // of it), its turns, its tokens, its cache reads and its cost, and the lines of the role
16
+ // `circuit` of the steps the circuit leads itself; the results the circuit keeps,
17
+ // `.claude/worktrees/<ticket>.fiches.jsonl`, each with its time: the launch of each session the
18
+ // circuit opens or resumes (`lancement`: its role and its session id, the register line's
19
+ // `agent`), the sheet of each session (`{ role, composant, fiche }`), the outcome of each proof
20
+ // (`resultat`: `ok`, its counts, `vide`) and of each check (`resultat`: `issue` vert, rouge or a-repasser, `finale`), each stop
21
+ // (`arret`, its name, `finale`), each developpeur resumed who changed nothing (`inchange`), the
22
+ // end of each questioning (`questionnement`: its `objet`, plan or decision, its `tours`, whether
23
+ // it changed the proposal, `change`, and its `issue`, AUCUNE, DÉSACCORD or MONTE), each end of a
24
+ // run on a stop it hands to the supervisor (`montee`: its code and its stop); the circuit's
25
+ // state, `.claude/worktrees/<ticket>.circuit.json`, for where the circuit ended. Every
26
+ // agent declares nothing: the bilan measures.
27
+ // - The steps and the roles are those of scripts/etapes.mjs. Only the circuit's roles count: an
28
+ // exploration, before the circuit, is out of it. Only the sessions the circuit launched count in
29
+ // the window, the categories, the tokens and the cost; a session of a circuit's role on the
30
+ // ticket the circuit did not launch, the supervisor's, counts apart (`seances.horsCircuit`: its
31
+ // number, by role, its cost).
32
+ // - The window: from the first turn of a session or a step to the last result of the circuit or
33
+ // the last turn; its length is the wall time.
34
+ // - Each turn of a session and each step finds its result: a step, the last outcome or stop of
35
+ // its step and component kept during it; a session, the first sheet of its role kept after its
36
+ // turn, before the next turn of any other, which names its component. A step `controles` whose
37
+ // outcome or stop is the final pass's (`finale`) is the final pass.
38
+ // - The categories, each instant once:
39
+ // - travail resté au lot: a session that delivered, the reviewer's included;
40
+ // - relance sans effet: a developer whose session the circuit follows with a stop
41
+ // « sans-livraison » or a resumption that changed nothing (`inchange`); a testwriter whose next
42
+ // proof finds the lot of the proof before it (the same counts of reds, greens of the lot,
43
+ // broken files and greens of HEAD), after a refused proof, or no file of the lot (`vide`:
44
+ // reds that exist already or of a build alone, no green of the lot); that proof; checks that
45
+ // play the same red again on a component none of its agents delivered to since its checks
46
+ // before (the final pass: none of any component's since the final pass before);
47
+ // - a session of the regulator, its answers to the questionneur among them, takes the category of
48
+ // what its decision gives, as a developer's: relance sans effet when the first session or step
49
+ // after its arbitrage, before the circuit ends, is without effect or a replay; travail resté au
50
+ // lot otherwise, a question sent up to the supervisor included;
51
+ // - questionnement: a session of the questionneur, and of the referent who answers it for the
52
+ // plan (kept: a look that did not write the proposal, as a review);
53
+ // - attente humaine: from each end of a run on a stop (`montee`) to the first turn of a session or
54
+ // step after it, or the next result of the circuit, the first of the two;
55
+ // - étapes automatiques: the other steps the circuit leads, but the final passes;
56
+ // - passes finales: the final passes, but those without effect or replayed;
57
+ // - rejeu d'étapes déjà vertes: the same, on a lot unchanged since green: checks green again,
58
+ // a testwriter run again on a proven lot, its proof;
59
+ // - entre deux étapes: the rest of the window.
60
+ // What is lost is the wall time out of the work, the questioning, the automatic steps and the
61
+ // final passes.
62
+ // - The bilan counts the sessions by role, the steps by their titles, the stops by kind (`arret`),
63
+ // the ends on a stop (the waits), the rounds of questioning, the proposals they changed and those
64
+ // they sent up to the supervisor, the tokens, the cache reads and the cost (a session without a cost
65
+ // counts 0); a session's tokens go to its turns by their time.
66
+ // - It is kept in the main tree, `.claude/worktrees/<ticket>.bilan.json`, and compares itself with
67
+ // the latest bilan kept for another ticket of the project, or with nothing: no figure of
68
+ // another project is written here.
69
+ import { spawnSync } from 'node:child_process';
70
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
71
+ import os from 'node:os';
72
+ import path from 'node:path';
73
+ import { pathToFileURL } from 'node:url';
74
+ import {
75
+ ARBITRAGE,
76
+ etapeDuCircuit,
77
+ etapeDuRole,
78
+ juge,
79
+ nomDuRole,
80
+ QUESTIONNEUR,
81
+ REGULATEUR,
82
+ SUITE,
83
+ } from './etapes.mjs';
84
+
85
+ // The role of a register line that a step of the circuit writes, and of its results.
86
+ const CIRCUIT = 'circuit';
87
+ // The gap allowed between a result and the turn or step it closes.
88
+ const JEU = 2000;
89
+ const MINUTE = 60_000;
90
+
91
+ // The categories, in the order the text lists them, with their names.
92
+ const CATEGORIES = [
93
+ ['travail', 'travail resté au lot'],
94
+ ['sansEffet', 'relance sans effet'],
95
+ ['questionnement', 'questionnement'],
96
+ ['attente', 'attente humaine'],
97
+ ['automatique', 'étapes automatiques'],
98
+ ['finales', 'passes finales'],
99
+ ['rejeu', "rejeu d'étapes déjà vertes"],
100
+ ['intervalles', 'entre deux étapes'],
101
+ ];
102
+ // The categories of the time kept: the rest is lost.
103
+ const GARDE = new Set(['travail', 'automatique', 'finales', 'questionnement']);
104
+ // The roles of the circuit, by the steps they work at: every step but the exploration, and the
105
+ // arbitrage.
106
+ const ROLES = new Set([...SUITE, ARBITRAGE].filter(e => e.numero !== '0').flatMap(e => e.qui));
107
+
108
+ // The results the circuit kept, `{ t, …entrée }`, in their order; an entry without a time counts
109
+ // nothing.
110
+ function resultats(entrees) {
111
+ return entrees
112
+ .map(e => ({ ...e, t: Date.parse(e.date ?? '') }))
113
+ .filter(e => !Number.isNaN(e.t))
114
+ .sort((x, y) => x.t - y.t);
115
+ }
116
+
117
+ // The signature of a proof's outcome <r>: the counts of its reds, greens of the lot, broken files
118
+ // and greens of HEAD.
119
+ const signature = r => [r.rouges, r.justifies + r.sans, r.casses, r.anciens].join(',');
120
+
121
+ // The outcome of a step's result <e>: green, « à repasser », or red; a stop is red, save « à
122
+ // repasser ».
123
+ const issue = e =>
124
+ e.arret ? (e.arret === 'a-repasser' ? 'a-repasser' : 'rouge') : (e.resultat?.issue ?? 'rouge');
125
+
126
+ // The length of the union of the intervals <l>.
127
+ function longueur(l) {
128
+ let total = 0;
129
+ let fin = -Infinity;
130
+ for (const [d, f] of [...l].sort((x, y) => x[0] - y[0])) {
131
+ if (f <= fin) continue;
132
+ total += f - Math.max(d, fin);
133
+ fin = f;
134
+ }
135
+ return total;
136
+ }
137
+ // The length of the interval [d, f] shared with the intervals <l>.
138
+ const commun = ([d, f], l) =>
139
+ longueur(l.map(([a, b]) => [Math.max(a, d), Math.min(b, f)]).filter(([a, b]) => b > a));
140
+
141
+ // The bilan of <ticket> from the register's lines <lignes> (of its project), the results the
142
+ // circuit kept <entrees> (`<ticket>.fiches.jsonl`) and the circuit's state <etat> (or null).
143
+ export function bilan({ ticket, lignes, entrees = [], etat = null, maintenant = Date.now() }) {
144
+ // The sessions the circuit launched, by their id.
145
+ const lancees = new Set(
146
+ entrees.filter(e => e.role === CIRCUIT && e.lancement).map(e => e.lancement.seance)
147
+ );
148
+ // Each agent's latest line on the ticket: it holds all its turns.
149
+ const derniere = new Map();
150
+ for (const l of lignes) {
151
+ if (l.ticket !== ticket || (l.role !== CIRCUIT && !ROLES.has(l.role))) continue;
152
+ const avant = derniere.get(l.agent);
153
+ if (!avant || Date.parse(l.fin) > Date.parse(avant.fin)) derniere.set(l.agent, l);
154
+ }
155
+ const hors = [...derniere.values()].filter(l => l.role !== CIRCUIT && !lancees.has(l.agent));
156
+ const agents = [...derniere.values()].filter(l => !hors.includes(l));
157
+ // One segment per turn of a session, one per step.
158
+ const segments = agents
159
+ .flatMap(l =>
160
+ (l.tours ?? [[Date.parse(l.debut), Date.parse(l.fin)]]).map(([d, f]) => ({
161
+ l,
162
+ d,
163
+ f,
164
+ role: l.role,
165
+ etape: l.role === CIRCUIT ? l.etape : null,
166
+ composant: l.role === CIRCUIT ? l.composant : null,
167
+ }))
168
+ )
169
+ .filter(s => s.f > s.d)
170
+ .sort((x, y) => x.d - y.d);
171
+ const gardes = resultats(entrees);
172
+ const debut = Math.min(...segments.map(s => s.d), Infinity);
173
+ const fin = Math.max(...segments.map(s => s.f), ...gardes.map(e => e.t), -Infinity);
174
+ const mur = Number.isFinite(debut) && fin > debut ? fin - debut : 0;
175
+
176
+ // Each segment and its result: a step's outcome or stop, a session's sheet.
177
+ const duCircuit = gardes.filter(e => e.role === CIRCUIT);
178
+ for (const s of segments) {
179
+ if (s.role === CIRCUIT) {
180
+ s.entree =
181
+ duCircuit.findLast(
182
+ e =>
183
+ (e.resultat || e.arret) &&
184
+ e.etape === s.etape &&
185
+ e.composant === s.composant &&
186
+ e.t >= s.d - JEU &&
187
+ e.t <= s.f + JEU
188
+ ) ?? null;
189
+ s.finale = s.etape === 'controles' && Boolean(s.entree?.resultat?.finale ?? s.entree?.finale);
190
+ } else {
191
+ s.suivant = segments.find(x => x.d >= s.f && x.l !== s.l)?.d ?? Infinity;
192
+ s.entree =
193
+ gardes.find(e => e.role === s.role && e.t >= s.f - JEU && e.t <= s.suivant + JEU) ?? null;
194
+ s.composant = s.entree?.composant ?? null;
195
+ }
196
+ }
197
+ const etapes = segments.filter(s => s.role === CIRCUIT);
198
+ const preuves = etapes.filter(s => s.etape === 'preuve');
199
+ for (const p of preuves) p.preuve = p.entree?.resultat ?? null;
200
+ // A proof and the category it gives the testwriter before it: the lot unchanged since the proof
201
+ // before, or none.
202
+ const avantLa = p => preuves.findLast(x => x.composant === p.composant && x.f <= p.d);
203
+ const parLaPreuve = p => {
204
+ const avant = avantLa(p);
205
+ if (!p.preuve) return null;
206
+ if (avant?.preuve && signature(avant.preuve) === signature(p.preuve)) {
207
+ return avant.preuve.ok ? 'rejeu' : 'sansEffet';
208
+ }
209
+ return null;
210
+ };
211
+ // Whether the circuit followed the developer's session <s> with a stop « sans-livraison » or a
212
+ // resumption that changed nothing.
213
+ const sansLivraison = s =>
214
+ duCircuit.some(
215
+ e =>
216
+ e.etape === 'developpeur' &&
217
+ e.composant === s.composant &&
218
+ (e.arret === 'sans-livraison' || e.inchange) &&
219
+ e.t >= s.f - JEU &&
220
+ e.t <= s.suivant + JEU
221
+ );
222
+
223
+ // The sessions first: the checks read what they delivered.
224
+ for (const s of segments.filter(x => x.role !== CIRCUIT && x.role !== REGULATEUR)) {
225
+ if (juge(s.role)) s.categorie = 'questionnement';
226
+ else if (s.role === 'developpeur') {
227
+ s.categorie = sansLivraison(s) ? 'sansEffet' : 'travail';
228
+ } else if (s.role === 'testwriter') {
229
+ const p = preuves.find(x => x.composant === s.composant && x.d >= s.f - JEU);
230
+ s.categorie = p ? (parLaPreuve(p) ?? (p.preuve?.vide ? 'sansEffet' : 'travail')) : 'travail';
231
+ } else s.categorie = 'travail';
232
+ }
233
+ // A session that changed a component's lot.
234
+ const livre = s =>
235
+ s.role !== CIRCUIT && s.categorie === 'travail' && etapeDuRole(s.role)?.parComposant;
236
+ for (const s of etapes) {
237
+ if (s.etape === 'preuve') {
238
+ s.categorie = parLaPreuve(s) ?? 'automatique';
239
+ } else if (s.etape === 'controles') {
240
+ const memes = x => x.etape === 'controles' && x.finale === s.finale;
241
+ const avant = etapes.findLast(
242
+ x => memes(x) && x.f <= s.d && (s.finale || x.composant === s.composant)
243
+ );
244
+ const change =
245
+ !avant ||
246
+ segments.some(
247
+ x =>
248
+ livre(x) &&
249
+ x.d >= avant.f - JEU &&
250
+ x.f <= s.d + JEU &&
251
+ (s.finale || x.composant === s.composant)
252
+ );
253
+ const i = s.entree ? issue(s.entree) : 'rouge';
254
+ s.categorie =
255
+ change || !avant.entree || issue(avant.entree) !== i
256
+ ? s.finale
257
+ ? 'finales'
258
+ : 'automatique'
259
+ : i === 'vert'
260
+ ? 'rejeu'
261
+ : 'sansEffet';
262
+ } else s.categorie = 'automatique';
263
+ }
264
+
265
+ // The ends of the runs on a stop, each the start of a wait for the supervisor.
266
+ const montees = duCircuit.filter(e => e.montee).map(e => e.t);
267
+ // The regulator's sessions last: each takes the category of the first session or step after its
268
+ // arbitrage, before the run ends.
269
+ const arbitrage = x => x.role === REGULATEUR || x.role === QUESTIONNEUR;
270
+ for (const s of segments.filter(x => x.role === REGULATEUR)) {
271
+ const suite = segments.find(x => x.d >= s.f - JEU && !arbitrage(x));
272
+ const coupe = suite && montees.some(t => t >= s.f - JEU && t <= suite.d);
273
+ s.categorie =
274
+ suite && !coupe && (suite.categorie === 'sansEffet' || suite.categorie === 'rejeu')
275
+ ? 'sansEffet'
276
+ : 'travail';
277
+ }
278
+
279
+ // The waits for the supervisor: from each end of a run on a stop to the answer, the run after.
280
+ const attentes = [];
281
+ for (const t of montees) {
282
+ const tour = segments.find(s => s.d > t)?.d ?? Infinity;
283
+ const suivant = gardes.find(e => e.t > t)?.t ?? Infinity;
284
+ const f = Math.min(tour, suivant, fin);
285
+ if (f > t) attentes.push([t, f]);
286
+ }
287
+
288
+ const vide = () => ({ ms: 0, n: 0, jetons: 0, cache: 0, cout: 0 });
289
+ const categories = Object.fromEntries(CATEGORIES.map(([k]) => [k, vide()]));
290
+ categories.attente = { ...vide(), ms: longueur(attentes), n: attentes.length };
291
+ const fenetre = s => [Math.max(s.d, debut), Math.min(s.f, fin)];
292
+ for (const s of segments) {
293
+ const [d, f] = fenetre(s);
294
+ if (f <= d) continue;
295
+ const ms = f - d - commun([d, f], attentes);
296
+ const c = categories[s.categorie];
297
+ c.ms += ms;
298
+ c.n++;
299
+ if (s.role !== CIRCUIT) {
300
+ // The tokens of a session go to its turns by their time.
301
+ const tout = (s.l.tours ?? [[s.d, s.f]]).reduce((m, [a, b]) => m + b - a, 0) || 1;
302
+ const part = (s.f - s.d) / tout;
303
+ c.jetons += (s.l.jetons ?? 0) * part;
304
+ c.cache += (s.l.cache ?? 0) * part;
305
+ c.cout += (s.l.cout ?? 0) * part;
306
+ }
307
+ }
308
+ categories.intervalles.ms = Math.max(
309
+ 0,
310
+ mur - longueur([...segments.map(fenetre).filter(([d, f]) => f > d), ...attentes])
311
+ );
312
+ for (const c of Object.values(categories)) {
313
+ c.jetons = Math.round(c.jetons);
314
+ c.cache = Math.round(c.cache);
315
+ }
316
+
317
+ const sessions = agents.filter(l => l.role !== CIRCUIT);
318
+ // The sessions <l> by role, in the order of the steps.
319
+ const parRoleDe = l => {
320
+ const out = {};
321
+ for (const role of new Set([...SUITE, ARBITRAGE].flatMap(e => e.qui))) {
322
+ const n = l.filter(x => x.role === role).length;
323
+ if (n) out[role] = n;
324
+ }
325
+ return out;
326
+ };
327
+ const parEtape = {};
328
+ for (const s of etapes) {
329
+ const e = etapeDuCircuit({ etape: s.etape, finale: s.finale });
330
+ const titre = e?.titre ?? s.etape;
331
+ parEtape[titre] = (parEtape[titre] ?? 0) + 1;
332
+ }
333
+ const arrets = {};
334
+ for (const e of duCircuit.filter(x => x.arret)) arrets[e.arret] = (arrets[e.arret] ?? 0) + 1;
335
+ // The ends of the questionings: their rounds, the proposals they changed, those sent up.
336
+ const fins = duCircuit.map(e => e.questionnement).filter(Boolean);
337
+ const questionnement = {
338
+ tours: fins.reduce((m, q) => m + (q.tours ?? 0), 0),
339
+ changees: fins.filter(q => q.change).length,
340
+ montees: fins.filter(q => q.issue !== 'AUCUNE').length,
341
+ };
342
+ const somme = k => sessions.reduce((m, l) => m + (l[k] ?? 0), 0);
343
+ return {
344
+ ticket,
345
+ calcule: new Date(maintenant).toISOString(),
346
+ debut: mur ? new Date(debut).toISOString() : null,
347
+ fin: mur ? new Date(fin).toISOString() : null,
348
+ mur,
349
+ statut: statutDe(etat),
350
+ perdu: CATEGORIES.filter(([k]) => !GARDE.has(k)).reduce((m, [k]) => m + categories[k].ms, 0),
351
+ categories,
352
+ seances: {
353
+ total: sessions.length,
354
+ parRole: parRoleDe(sessions),
355
+ horsCircuit: {
356
+ total: hors.length,
357
+ parRole: parRoleDe(hors),
358
+ cout: hors.reduce((m, l) => m + (l.cout ?? 0), 0),
359
+ },
360
+ },
361
+ etapes: parEtape,
362
+ arrets,
363
+ questionnement,
364
+ jetons: somme('jetons'),
365
+ cache: somme('cache'),
366
+ cout: somme('cout'),
367
+ };
368
+ }
369
+
370
+ // Where the circuit of the state <etat> ended, in words.
371
+ function statutDe(etat) {
372
+ if (etat?.etape === 'integre') return 'intégré';
373
+ if (etat?.raison) return `arrêt ${etat.raison}${etat.code != null ? ` (${etat.code})` : ''}`;
374
+ return 'en cours';
375
+ }
376
+
377
+ const minutes = ms => Math.round(ms / MINUTE);
378
+ const pourcent = (x, sur) => (sur ? Math.round((100 * x) / sur) : 0);
379
+ const dollars = x => (x ?? 0).toFixed(2).replace('.', ',');
380
+ const millions = x => ((x ?? 0) / 1e6).toFixed(2).replace('.', ',');
381
+
382
+ // The key figures of the bilan <b> in one sentence, as the ticket's card shows them.
383
+ export const enBref = b =>
384
+ `Bilan : ${minutes(b.mur)} min, ${pourcent(b.perdu, b.mur)} % perdus, ${b.seances.total} séances, ${dollars(b.cout)} $.`;
385
+
386
+ // The text of the bilan <b> at the ticket, short and in figures, compared with the bilan
387
+ // <avant> (another ticket's, or null).
388
+ export function texteDuBilan(b, avant) {
389
+ const lesRoles = parRole =>
390
+ Object.entries(parRole)
391
+ .map(([r, n]) => `${n} ${nomDuRole(r)}`)
392
+ .join(', ');
393
+ const roles = lesRoles(b.seances.parRole);
394
+ const h = b.seances.horsCircuit;
395
+ const horsCircuit = h.total
396
+ ? ` ; ${h.total} séance${h.total > 1 ? 's' : ''} hors circuit, lancée${h.total > 1 ? 's' : ''} par le superviseur (${lesRoles(h.parRole)}), ${dollars(h.cout)} $`
397
+ : '';
398
+ const tete = `${b.statut} en ${minutes(b.mur)} min ; ${minutes(b.perdu)} min perdues (${pourcent(b.perdu, b.mur)} %) ; ${b.seances.total} séances${roles ? ` (${roles})` : ''}${horsCircuit} ; ${dollars(b.cout)} $ ; ${millions(b.jetons)} M jetons, ${millions(b.cache)} M lus du cache.`;
399
+ const paye = new Set(['travail', 'sansEffet', 'questionnement']);
400
+ const q = b.questionnement ?? { tours: 0, changees: 0 };
401
+ const pluriel = (n, mot) => `${n} ${mot}${n > 1 ? 's' : ''}`;
402
+ const lignes = CATEGORIES.map(([k, nom]) => {
403
+ const c = b.categories[k];
404
+ const plus =
405
+ k === 'attente' && c.n
406
+ ? ` (${pluriel(c.n, 'montée')})`
407
+ : k === 'questionnement'
408
+ ? `, ${dollars(c.cout)} $ (${pluriel(q.tours, 'tour')}, ${pluriel(q.changees, 'décision')} ${q.changees > 1 ? 'changées' : 'changée'})`
409
+ : paye.has(k)
410
+ ? `, ${dollars(c.cout)} $`
411
+ : '';
412
+ return `- ${nom} : ${minutes(c.ms)} min${plus}`;
413
+ });
414
+ const arrets = Object.entries(b.arrets)
415
+ .sort((x, y) => y[1] - x[1])
416
+ .map(([nom, n]) => `${n} ${nom}`)
417
+ .join(', ');
418
+ const base = avant
419
+ ? `Face au bilan de ${avant.ticket} : mur ${minutes(avant.mur)} → ${minutes(b.mur)} min, perte ${pourcent(avant.perdu, avant.mur)} → ${pourcent(b.perdu, b.mur)} %, séances ${avant.seances.total} → ${b.seances.total}, coût ${dollars(avant.cout)} → ${dollars(b.cout)} $.`
420
+ : "Aucun bilan d'avant dans ce projet : pas de base.";
421
+ return [tete, ...lignes, `Arrêts : ${arrets || 'aucun'}.`, base].join('\n');
422
+ }
423
+
424
+ const dossier = racine => path.join(racine, '.claude', 'worktrees');
425
+ const SUFFIXE = '.bilan.json';
426
+
427
+ // Keeps the bilan <b> in the main tree <racine>.
428
+ export function garder(racine, b) {
429
+ const f = path.join(dossier(racine), `${b.ticket}${SUFFIXE}`);
430
+ const provisoire = `${f}.${process.pid}`;
431
+ writeFileSync(provisoire, `${JSON.stringify(b, null, 2)}\n`);
432
+ renameSync(provisoire, f);
433
+ }
434
+
435
+ // The bilans kept in the main tree <racine>, by ticket; a file that does not parse is left out.
436
+ export function bilansGardes(racine) {
437
+ const d = dossier(racine);
438
+ const out = {};
439
+ if (!existsSync(d)) return out;
440
+ for (const n of readdirSync(d).filter(x => x.endsWith(SUFFIXE))) {
441
+ try {
442
+ const b = JSON.parse(readFileSync(path.join(d, n), 'utf8'));
443
+ if (b?.ticket) out[b.ticket] = b;
444
+ } catch {
445
+ // A torn bilan counts nothing.
446
+ }
447
+ }
448
+ return out;
449
+ }
450
+
451
+ // The latest bilan kept for another ticket than <ticket>, or null.
452
+ export function precedent(racine, ticket) {
453
+ return (
454
+ Object.values(bilansGardes(racine))
455
+ .filter(b => b.ticket !== ticket)
456
+ .sort((x, y) => Date.parse(y.calcule ?? 0) - Date.parse(x.calcule ?? 0))[0] ?? null
457
+ );
458
+ }
459
+
460
+ // The lines of the register <texte> of the project at <racine>: its project the main tree, or a
461
+ // folder of it, which a session run in that folder may name; a torn line counts nothing.
462
+ export function lignesDuProjet(texte, racine) {
463
+ return texte.split('\n').flatMap(x => {
464
+ try {
465
+ const p = String(JSON.parse(x)?.projet ?? '');
466
+ return p === racine || p.startsWith(`${racine}${path.sep}`) ? [JSON.parse(x)] : [];
467
+ } catch {
468
+ return [];
469
+ }
470
+ });
471
+ }
472
+
473
+ // The bilan of <ticket> computed from the main tree <racine>: its register, the results its
474
+ // circuit kept, its circuit's state.
475
+ function calculer(racine, ticket) {
476
+ const registre =
477
+ process.env.GRILLHOUSE_REGISTRE ??
478
+ path.join(os.homedir(), '.claude', 'grillhouse', 'registre.jsonl');
479
+ const lignes = lignesDuProjet(existsSync(registre) ? readFileSync(registre, 'utf8') : '', racine);
480
+ const fiches = path.join(dossier(racine), `${ticket}.fiches.jsonl`);
481
+ const entrees = (existsSync(fiches) ? readFileSync(fiches, 'utf8') : '')
482
+ .split('\n')
483
+ .flatMap(l => {
484
+ try {
485
+ return l ? [JSON.parse(l)] : [];
486
+ } catch {
487
+ return [];
488
+ }
489
+ });
490
+ const f = path.join(dossier(racine), `${ticket}.circuit.json`);
491
+ let etat = null;
492
+ try {
493
+ etat = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
494
+ } catch {
495
+ etat = null;
496
+ }
497
+ return bilan({ ticket, lignes, entrees, etat });
498
+ }
499
+
500
+ // The bilan of <ticket> computed, kept in the main tree <racine>, and its text, compared with the
501
+ // bilan before it: what the circuit writes at the ticket.
502
+ export function ecrireLeBilan(racine, ticket) {
503
+ const b = calculer(racine, ticket);
504
+ const avant = precedent(racine, ticket);
505
+ garder(racine, b);
506
+ return texteDuBilan(b, avant);
507
+ }
508
+
509
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
510
+ const [ticket, ...options] = process.argv.slice(2);
511
+ if (!ticket || ticket.startsWith('-')) {
512
+ process.stderr.write('usage: bilan.mjs <ticket> [--garder]\n');
513
+ process.exitCode = 2;
514
+ } else {
515
+ const commun = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
516
+ encoding: 'utf8',
517
+ });
518
+ const racine = commun.status === 0 ? path.dirname(commun.stdout.trim()) : process.cwd();
519
+ const b = calculer(racine, ticket);
520
+ process.stdout.write(`${texteDuBilan(b, precedent(racine, ticket))}\n`);
521
+ if (options.includes('--garder')) garder(racine, b);
522
+ }
523
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // The night branch guard: refuses a push to origin that reaches the branch named by
3
+ // `grillhouse.poussee.nuit`, unless the night makes the push. Run by the pre-push hook of the
4
+ // frame with git's arguments (remote name, remote URL) and, on the standard input, the lines
5
+ // « <local ref> <local sha> <remote ref> <remote sha> » git writes.
6
+ //
7
+ // - Only the remote `origin` is watched, the one the night pushes to. A push of several refs one
8
+ // of which reaches the branch, and the deletion of that remote branch, are refused whole.
9
+ // - The key is read as written in the package.json at the root of the working tree the push starts
10
+ // from; without the key (or null) nothing is refused. An invalid value (see
11
+ // scripts/poussee-cles.mjs) refuses the push, with a line that cites the key.
12
+ // - The night makes itself known by GRILLHOUSE_NUIT=1, which scripts/nuit.sh sets for its push
13
+ // alone. It is a mark, not a secret: a human who sets it is trusted.
14
+ // - Exit 0 lets the push go on; exit 1 refuses it, the reason on the standard error.
15
+ import { execFileSync } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { lirePoussee } from './poussee-cles.mjs';
20
+
21
+ // The refusals for a push to `remote` of `refs` (the lines git writes) given the package.json
22
+ // object `pkg`; an empty list lets the push go on.
23
+ export function refus(remote, refs, pkg, env = process.env) {
24
+ if (remote !== 'origin' || env.GRILLHOUSE_NUIT === '1' || !pkg) {
25
+ return [];
26
+ }
27
+ const { nuit, fautes } = lirePoussee(pkg);
28
+ if (fautes.length) {
29
+ return fautes.map(
30
+ f => `⛔ La clé ${f.cle} vaut ${f.valeur} : valeur invalide, la poussée est refusée.`
31
+ );
32
+ }
33
+ if (nuit && refs.some(r => r.distante === `refs/heads/${nuit}`)) {
34
+ return [`⛔ Seule la nuit pousse origin/${nuit} (grillhouse.poussee.nuit), après son vert.`];
35
+ }
36
+ return [];
37
+ }
38
+
39
+ // The pushed refs of git's standard input.
40
+ export function lireRefs(texte) {
41
+ return texte
42
+ .split('\n')
43
+ .filter(l => l.trim())
44
+ .map(l => {
45
+ const [locale, sha, distante] = l.trim().split(/\s+/);
46
+ return { locale, sha, distante };
47
+ });
48
+ }
49
+
50
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
51
+ const racine = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
52
+ const fichier = path.join(racine, 'package.json');
53
+ const pkg = existsSync(fichier) ? JSON.parse(readFileSync(fichier, 'utf8')) : null;
54
+ const lignes = refus(process.argv[2], lireRefs(readFileSync(0, 'utf8')), pkg);
55
+ for (const l of lignes) {
56
+ process.stderr.write(`${l}\n`);
57
+ }
58
+ process.exitCode = lignes.length ? 1 : 0;
59
+ }