@roomi-fields/grillhouse 0.0.0-stage → 0.23.0

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,4375 @@
1
+ #!/usr/bin/env node
2
+ // The circuit of one ticket: for each component its title names, in order, the provider first, the
3
+ // testwriter, the proof of the red, the developpeur and the checks; then the reviewer, and on its
4
+ // ACCEPTÉ the integration, which commits the lot on the ticket's branch and puts that branch in the
5
+ // queue; then the queue, which brings it onto main, pushes and reads the CI, and the circuit ends
6
+ // on the state of the ticket the queue leaves. The supervisor runs it once, in the background, and
7
+ // its end wakes him: at 0 the ticket is closed; at 75 he runs `--reprendre` again once the machine
8
+ // is at rest; at « question » (18) the regulator's question goes to him: he answers it
9
+ // himself, or carries it to the owner when it is the owner's, then runs `--reponse "<texte>"` with
10
+ // the answer, which enters the plan before the regulator resumes; at « composants-manquants »
11
+ // (21) he adds the components to the title or answers why they stay out; at « questionnement »
12
+ // (22) he changes the plan or keeps it, and answers; at « references » (23), a reference the
13
+ // regeneration of 3.1 changed and the plan does not name, which goes to the regulator first; a
14
+ // decision of the owner given out of any
15
+ // such stop, the circuit running or not, he gives by `--decision "<texte>"`; at
16
+ // any other code, the stop is one the regulator could not settle, or one the
17
+ // table of the stops hands to him. On a stop other than 0 and 21, the circuit reads first the
18
+ // table of the stops (scripts/etapes.mjs, TABLE_DES_ARRETS), its written policy, as Zuul and
19
+ // GitHub's merge queue write their bounded retries: a gesture the table names, it applies itself;
20
+ // for what the table does not settle, the circuit launches a session of the regulator itself, as
21
+ // the scheduler of an integration (Zuul, bors) calls the judgement at a stop and applies the
22
+ // decision it gets: the regulator settles, and its sheet is its decision, which the circuit checks
23
+ // on the state, writes at the ticket and applies in the same process. So the supervisor is woken
24
+ // only by the end of the lot, a question of the regulator, or a stop neither the table nor the
25
+ // regulator settled.
26
+ //
27
+ // npx grillhouse circuit <ticket> [--reprendre] [--contexte <texte>]
28
+ // npx grillhouse circuit <ticket> --depuis <testwriter|developpeur> <composant> --contexte <texte>
29
+ // npx grillhouse circuit <ticket> --reponse <texte>
30
+ // npx grillhouse circuit <ticket> --decision <texte>
31
+ //
32
+ // The circuit decides on structured results only (scripts/resultats.mjs, the one module of their
33
+ // formats), as Bazel's Build Event Protocol and the JUnit reports carry the outcome of a build:
34
+ // the sheet that ends each session, the summaries of Turborepo, the JSON reports of Vitest and the
35
+ // SARIF of the guards. It reads no comment, no answer and no log to decide (a guard holds it:
36
+ // tests/unit/circuit-sans-texte-libre.spec.ts); the handovers at the ticket are for the human and
37
+ // for the next session of their author, the log for the human and the regulator.
38
+ //
39
+ // - The components are read in the ticket's title (`bd show <ticket> --json`), after its last
40
+ // « — », separated by commas: each one a path (`packages/<nom>`, or `.`), or the name of a
41
+ // package, which becomes its directory (scripts/paquets.mjs), read in the ticket's copy when it
42
+ // exists, else in the main tree. A component that names neither stops the call, exit 2, named.
43
+ // - Before the testwriter of the first component (a run that starts there), the circuit surveys the
44
+ // components (step 1.1 of scripts/etapes.mjs) the ticket's Architecture and criteria reach (scripts/composants-affectes.mjs), as
45
+ // the « affected components » of a design document: a component the survey finds, the title
46
+ // does not name and no admission covers (`composants-affectes.mjs <ticket> --admettre`) stops it
47
+ // at once, before any session and without a regulator, at
48
+ // « composants-manquants », the missing components at the head of its report. The supervisor
49
+ // adds it to the title, the provider first, and runs the circuit again; or he answers why it
50
+ // stays out, `--reponse <texte>`, which the circuit writes at the ticket and keeps beside the
51
+ // copy (`.claude/worktrees/<ticket>.composants.json`): the components answered for pass from then
52
+ // on, and the run starts from the first component.
53
+ // - Then, before any session of the lot, the questioning of the plan (step 1.2 of
54
+ // scripts/etapes.mjs), as Amazon's bar raiser, a cockpit's challenge and response and the
55
+ // devil's advocate of a design review: a session of the questionneur out of the envelope, as the
56
+ // regulator's, read-only (no tool of writing, every write of bd refused, scripts/droits.mjs), its
57
+ // instruction from the eighth template (the proposal, the métacadre, the rubrics « Comment on
58
+ // arbitre » and « Ce qui monte au superviseur » of the project's CLAUDE.md, the ticket's
59
+ // description and the survey of its components; no list of questions), its sheet `issue`
60
+ // QUESTIONS (each question with what it aims at), AUCUNE or DÉSACCORD. On QUESTIONS the technical
61
+ // referent answers for the plan (`--agent referent-technique`, a fresh session, the eleventh
62
+ // template, then resumed with the tenth), its sheet `reponses` (CHANGE and what the plan becomes,
63
+ // or GARDE and why) and the `titre` or `description` the plan becomes, which the circuit writes
64
+ // to the ticket (`bd update --title`, `-d`; a title whose components the repository does not
65
+ // hold stays); the questionneur's session is resumed with the answers (the ninth template); four
66
+ // rounds at most (the loop `questionnement` of 1.2, `questionnement` in the state while it runs). Every question
67
+ // and answer is written at the ticket (`Circuit · <composant> · questionnement : …`) and the end
68
+ // kept with the circuit's results (`questionnement`: objet, tours, change, issue, the plan's
69
+ // fingerprint). AUCUNE goes on, the survey played again on a plan changed; a plan whose title
70
+ // and description passed a questioning, or the supervisor's answer, passes unquestioned. A
71
+ // DÉSACCORD, or a fourth round still questioned, stops at « questionnement », what remains at the
72
+ // head of its report; `--reponse <texte>` writes the answer at the ticket and lets the plan pass,
73
+ // from the first component.
74
+ // - Each session is a launch of scripts/enveloppe/lancer.sh, a child process the circuit waits
75
+ // for, in its own process group, its errors written to a file: a session outlives the death of
76
+ // its circuit (its caller dead, the circuit with it), and the state names its group while it
77
+ // runs. The instruction is written from the first template of
78
+ // mods/grillhouse/skills/pitmaster/references/consigne-agent.md, each `{{champ}}` filled: the ticket and
79
+ // its subject, the component, the ticket's description (its criteria) and its notes (a section
80
+ // absent without notes), both read with `bd show` at the session's launch, the report of the
81
+ // step before (the proof, the reds of a check), the last handover of the session's author
82
+ // (`<rôle> <composant>`) at the ticket, for a developpeur without one yet the last of the
83
+ // testwriter of his component, else « aucune » (no other component's handover, nor a verdict), and
84
+ // the line `CONTEXTE :`: the text of `--contexte`, for each agent of a run without `--depuis`,
85
+ // for each agent of the component it plays again under `--depuis`. The session's own exit code
86
+ // is the one lancer.sh writes to `.claude/worktrees/<ticket>.fin`; a launch that leaves none stopped before its session.
87
+ // - Each session of the testwriter, the developpeur, the reviewer, the regulator, the questionneur and
88
+ // the technical referent ends on its sheet
89
+ // (« la fiche »): claude runs with `--output-format json --json-schema <the schema of its role>`
90
+ // (scripts/resultats.mjs), and its JSON result carries the sheet in `structured_output`, which
91
+ // the circuit reads (`lireFiche`): lancer.sh writes that result to
92
+ // `.claude/worktrees/<ticket>.resultat.json`, and a session out of the envelope prints it. Each
93
+ // sheet read is kept with the ticket's sheets, `.claude/worktrees/<ticket>.fiches.jsonl` (`{
94
+ // date, role, composant, fiche }`, the lot a review read joined to its sheet, `lot`), beside the
95
+ // circuit's own results: the report of each proof and check, which the reviewer receives, with
96
+ // its outcome (`resultat`), each stop (`arret`), each developpeur resumed who changed nothing
97
+ // (`inchange`), each session launched (`lancement`, its role and its session id), each end of a
98
+ // run on a stop (`montee`), which the bilan reads; and each of its discoveries (`decouvertes`) filed closed
99
+ // under the ticket (scripts/ticket.sh, label `decouverte`). A session that ends without a sheet
100
+ // that conforms (none, one out of its schema, a code other than 0) is resumed once, the same
101
+ // session, with the fifth template, which names what lacks; a second one stops the circuit at
102
+ // « sans-fiche ». The handover at the ticket (`bd comments add`) stays, for the human.
103
+ // - A fresh session of the testwriter or the developpeur opens under a session id the circuit sets
104
+ // (`lancer.sh --seance <uuid>`, claude's `--session-id`), kept in the state (`reprise`, its
105
+ // author and id). A relaunch of that agent on its step and component (the testwriter after a failed
106
+ // proof, the developpeur after red checks) resumes that session (`lancer.sh --reprise <id>`,
107
+ // claude's `--resume`, which finds its transcript in the copy's folder of transcripts, which
108
+ // the envelope mounts in writing over the real one) with the sixth template of consigne-agent.md
109
+ // (the ticket, the component, what changed, the new report), save the last attempt (the
110
+ // testwriter's third session, the developpeur's on the strong model), a fresh and whole session. A
111
+ // resumption that ends with a code other than 0, or without a sheet that conforms, writes so at
112
+ // the ticket and falls back on a fresh and whole session. A resumed session keeps its id, and so
113
+ // its one line in the register, its latest end.
114
+ // - A session whose transcripts (`~/.claude/projects/<copy in dashes>/`, as scripts/registre.mjs
115
+ // reads them) and log (its preparation writes there before the session opens) stand still for
116
+ // 20 minutes (IMMOBILE_MS; GRILLHOUSE_CIRCUIT_IMMOBILE_MS replaces
117
+ // it, in milliseconds) is stopped: SIGTERM, then SIGKILL, to the launcher's group.
118
+ // - Each comment at the ticket carries its author, which bd reads in BEADS_ACTOR (ACTEUR): each
119
+ // session runs with the author of its role (`testwriter <composant>`, `developpeur <composant>`,
120
+ // `relecteur`, `regulateur`), which the envelope passes on; the circuit writes its own
121
+ // lines under `circuit` and the reviewer's verdict under `relecteur`, and the queue writes under
122
+ // `file` (scripts/integration/file.mjs), whose last word the circuit names. The rights of every
123
+ // session refuse bd's `--actor`, `-a`/`--author` and the variable itself (AUTEUR_FIXE of
124
+ // scripts/droits.mjs, in the settings of every role's session), so that no agent changes its
125
+ // author.
126
+ // - The sheet of the testwriter and of the developpeur carries its status (`statut`): FAIT goes on as
127
+ // below; BESOIN (its `question`) stops the circuit at once at « besoin », the question and the
128
+ // handover as its report, with no session more; so does BLOQUÉ (its `raison`), at
129
+ // « hors-portee », save for a developpeur whose sheet sends the red to a testwriter (`renvoi`,
130
+ // below), which is that sending. The regulator settles either stop, as any other.
131
+ // - A component that carries no test (sansTests: in a repository of packages, no package with a
132
+ // script `test:vitest` lies in it or holds it, as `npm test -- <chemin>` refuses a path out of
133
+ // every package or in a package without that script) skips the testwriter and the proof: the
134
+ // circuit writes `Circuit · <composant> · testwriter : aucun test …` at the ticket and launches its
135
+ // developpeur, the reason as his report, then his checks.
136
+ // - Each command of the proof and of the checks runs with its own results directory
137
+ // (`GRILLHOUSE_RESULTATS`), where Turborepo writes its summary (scripts/tests.mjs,
138
+ // scripts/verifier.mjs), Vitest its JSON report (scripts/vitest-config/resultats.mjs) and each
139
+ // guard its SARIF; its output goes to the log, for the human. Each `npm test` of them carries
140
+ // the mark `GRILLHOUSE_DEPUIS_LE_VERT` (scripts/dernier-vert.mjs): in a package named whole,
141
+ // the tests that play are those Vitest relates to the files changed since the last green of the
142
+ // copy's branch (main's while the copy has none), the files named among them, as `npm run verifier` measures itself; a lot that
143
+ // changes one test plays that test, and those whose graph it reaches.
144
+ // - Each automatic pass (each command of the proof and of the checks, the final pass, the
145
+ // integration and the queue) plays under a progress directory of its own
146
+ // (`GRILLHOUSE_AVANCEMENT`, scripts/avancement.mjs), which its state names while it plays
147
+ // (`avancement`: `{ dossier }`, null between two), as Bazel counts its actions: Vitest writes
148
+ // there the files done and planned of each task, the queue the steps of its CI; the board shows
149
+ // them on the ticket's line (scripts/tableau.mjs). Its total is kept at its end under its kind
150
+ // (the step, the component and the command), which the next pass of that kind takes while all
151
+ // its tasks have not started. The survey of the components names its start in the state while it
152
+ // runs (`releve`).
153
+ // - The proof of the red plays the new tests alone, as test-driven development runs the test it
154
+ // has just written to see it red, not the whole suite: one run per package, `npm test -- --force
155
+ // <files>` in the copy, the test files the testwriter's lot adds or changes in the component (git
156
+ // status of the copy, the testwriter's files as the integration shares them, `isTesterFile`),
157
+ // played for real, since a run replayed from Turborepo's cache writes no report; the other
158
+ // tests of the component play at its checks and at the final pass. With no file of the lot in
159
+ // the component (a reader whose provider changed a type, no new test to write), each package of
160
+ // the component that has a script `test:vitest` plays whole, `npm test -- <package>`: main is
161
+ // green, so a test HEAD holds that turns red in the copy is a regression of the lot, a red,
162
+ // written `rouge existant : <file> › <name> — <message>`, and so is a file that does not load
163
+ // while no build of the component fails (it loaded at HEAD); its greens do not count. A measure
164
+ // on the same tree is not played again, as Turborepo replays a task whose inputs did not move: a
165
+ // testwriter relaunched who changes no file of the copy (an expected green added to his sheet
166
+ // alone) is judged on the results of the proof already played, his sheet read again. A file of
167
+ // the lot that does not load proves nothing; a failed build of a package of the component (a
168
+ // task `<package>#build` of the summary) is a red, as not compiling is failing, named with its
169
+ // log, and the files it keeps from loading play at the checks, after the developpeur; a failed
170
+ // build of a package out of the component leaves them broken. A green test passes only when a
171
+ // sheet of the testwriter of the component names it in `verts_attendus`: its file from the root
172
+ // and its full name (or its title), with its reason; one without `test`, for a test file HEAD
173
+ // holds, justifies each green test of that file and excuses no red; on a new file it justifies
174
+ // nothing, and the report says so for that file. A green test HEAD already holds has nothing to
175
+ // prove: the version of HEAD of each file plays in the same run, from a sibling file
176
+ // (`<nom>.circuit-avant.test.<ext>`, removed after it), and a green test whose full name HEAD's
177
+ // version holds, green there too, is set aside, counted; a body changed under the same name is
178
+ // not told apart. The proof passes with a red (of the lot, existing or of a build), even without
179
+ // a file of the lot in the component: a testwriter with no new test to write says so in his
180
+ // handover, the existing reds are enough. It fails with
181
+ // neither a file of the lot nor a red, with a broken file of the lot, or with a green of the lot
182
+ // no expected green justifies. A failed proof relaunches the testwriter with its report, twice at
183
+ // most. The report (reds, justified greens) is written at the ticket. A proof with a red
184
+ // launches the developpeur, and so does a proof without a red when a finding of the reviewer
185
+ // names him; else it skips him: the lot is a lot of tests alone.
186
+ // - A developpeur is launched on a red, of a proof or of a check, on a finding of the reviewer, or
187
+ // in a component without tests: a session that changes no file of its component (`git status
188
+ // --porcelain` of its directory and the content of each file it lists, taken before and after
189
+ // the session; the sheet and the handover do not count) stops the circuit at « sans-livraison »,
190
+ // its report naming the component and sending to the developpeur's handover.
191
+ // - A sheet of the developpeur whose `renvoi` names `testwriter <composant>` (a component of the title
192
+ // up to his own) says the red that remains lies in the tests of that component: the circuit
193
+ // plays it as a `RELANCE --depuis testwriter <composant>` of the regulator, the sending and the
194
+ // handover as its context, whether or not the session changed a file: that component's testwriter,
195
+ // the sending on his line CONTEXTE, then its steps, the components after it as `--depuis` plays
196
+ // them, the final pass and the reviewer; in a round of `renvois` (a RENDU, a `--depuis`), the
197
+ // renvois of his component and of the components after it stay pending, each played after it with
198
+ // its findings, as a queue of jobs grows and is never replaced. A component out of the title or
199
+ // after his own is no such sending. The same sending twice in a row, with no green checks between them, stops at
200
+ // « sans-livraison ».
201
+ // - The checks after the developpeur of a component judge that component, as Bazel plays the
202
+ // targets of a change: `npm test -- <test files of the lot>` that lie in it (and those of the
203
+ // shared test material whose package depends on none of the components after it), `npm test --
204
+ // <package>` for each of its packages, touched by the lot or not (a package without a script
205
+ // `test:vitest` plays only when the lot touches it), then the guards in the envelope of the
206
+ // component (`GRILLHOUSE_ENVELOPPE=<composant> npm run grillhouse:gardes`, and `npm run gardes`
207
+ // when the project has it). Each red is read in the results of its command: a
208
+ // failed test of the reports of Vitest, a finding of the guards' SARIF, a failed build or task of
209
+ // the summary; a red check none of them situates is one red of the command. Each red belongs to
210
+ // the component that holds its file (or its task's package), the deepest; one without a file
211
+ // (the root's, a project's guard without SARIF), or in a package no component of the title
212
+ // holds, belongs to none. Out of the final pass, the reds of the component are its developpeur's;
213
+ // the others wait for the final pass: another component's, a finding of a guard on a file of
214
+ // another package (the SARIF names its file and its package), and a red without a file, the
215
+ // global guards': they play at the final pass alone.
216
+ // - After the last component of the run, the final pass: `npm run verifier`, what the
217
+ // integration plays, as bors plays on a lot the very check of the merge. Turborepo's cache
218
+ // and the last green hold its cost: a task nothing moved replays from it, and in a package
219
+ // that plays again, the tests Vitest relates to the files changed since the last green of the
220
+ // copy (scripts/dernier-vert.mjs, main's while the copy has none); what the lot reaches plays again,
221
+ // by the dependencies, the inputs of the tasks and `grillhouse.lit` (a reader out of the title
222
+ // whose tests read a directory the lot changes included). After a review, save on the lot it
223
+ // accepted, the final pass is restricted to what changed since the lot it read and to its
224
+ // dependents, as Turborepo selects them (`laPasseFinale`): the circuit keeps the copy as it
225
+ // stands as a commit on that lot (`garderLeLot`), and plays `npm run verifier --
226
+ // --filter=...[<lot relu>...<copie>] --filter=//`, the root's tasks (the guards) with them;
227
+ // Turborepo reads a range from its merge base, the lot read, where a single `[<commit>]` would
228
+ // be read against HEAD. The whole final pass green keeps the tree of its lot (`verifie`). A red
229
+ // goes to the developpeur of the first component, the provider first, that owns one. A red in a
230
+ // package no component of the title holds, or that no component owns, stops at « controles »:
231
+ // the regulator decides, who refers the component's entry in the title upward, or has the lot
232
+ // corrected. A developpeur
233
+ // whose reds are all findings to settle (`a-arbitrer`) is not launched: the circuit stops at
234
+ // « controles ». From then on every check is the final pass.
235
+ // - The regeneration of the references (step 3.1 of scripts/etapes.mjs), as `rustc --bless`,
236
+ // `jest -u` and `cargo insta accept`: a project declares in its package.json the references it
237
+ // derives from the whole copy, which no envelope shows (a fingerprint of its output, a ceiling
238
+ // of a count), `grillhouse.references`: each `{ nom, commande, fichiers, tests, gardes }`, its
239
+ // name, the shell command that writes it again from the root of the copy, the files it writes
240
+ // (paths, directories or glob patterns, `*` and `**`, from the root, as the inputs of
241
+ // Turborepo), the test files that judge it, as `rustc --bless` ties a test to its expected
242
+ // outputs, and the guards of the project that judge it, by the name their SARIF finding carries
243
+ // (its guard or its rule). A final pass whose every red is one of the references declared (a
244
+ // failed test of one of their test files, a finding of one of their guards, whatever file it
245
+ // aims at, or a failed test or a finding on a file they cover) sends no developpeur: the circuit
246
+ // keeps the copy as a lot, runs each command on
247
+ // the whole copy, out of the envelope, under the lock of the machine, its output added to the
248
+ // log, keeps the copy again on that lot, then plays the final pass restricted to what the
249
+ // regeneration changed and its dependents (`--filter=...[<avant>...<après>] --filter=//`); that
250
+ // pass green after a whole one keeps its tree as verified. The files regenerated stay in the
251
+ // copy: the lot carries them, and the reviewer reads them with the rest of the lot, the step's
252
+ // report among the circuit's. A reference changed that the ticket's description names neither
253
+ // by its name nor by one of its files, a file changed that the files of no reference cover, a command at
254
+ // a code other than 0, a regeneration that changes nothing, or a pass after it red again on the
255
+ // references, stops at « references » (23), the copy restored as it was before the
256
+ // regeneration, each reference and file named: the regulator decides. Without the declaration,
257
+ // the step never runs.
258
+ // - The proof, the checks and the final pass are heavy passes: each holds the lock of the machine
259
+ // (scripts/verrou-de-machine.mjs), shared by every repository of the machine, and waits, the
260
+ // wait written to the log, while another measure holds it; the wait bounded, the pass is at 75,
261
+ // « à repasser ».
262
+ // - The report a developpeur receives names his own reds first, each on its line (test or
263
+ // finding, file and line, message, the beginning of a failed build's own log), then the count
264
+ // of the others and the path of the log; the report of a stop names every red with its
265
+ // component.
266
+ // - A check at 75 (« à repasser ») out of the final pass plays once more; verifier plays its
267
+ // tasks at 75 again itself, at rest, and its 75 is the final pass's. A red relaunches the
268
+ // developpeur with its report, twice at most, the third session on the strong model
269
+ // (`lancer.sh --modele opus --effort high`): three sessions for a component's checks, and three
270
+ // for the whole final pass whatever the components its reds go to (the last component's
271
+ // sessions counted in it), so that two components sending a red to each other stop at
272
+ // « controles ».
273
+ // - After the final pass, green, the reviewer: a session out of the envelope, which sees the whole
274
+ // repository with its index, claude itself (`claude -p --agent relecteur --session-id <uuid>`,
275
+ // CLAUDE_BIN replaces claude) in the ticket's copy, on Opus, under the rights and the
276
+ // context of its role (scripts/droits.mjs: the commands its skill names, its index), its instruction from the second template of
277
+ // consigne-agent.md: the ticket, its components, the path of the claims file (the ticket's
278
+ // description as `bd show` gives it at the session's launch, written by the circuit in its
279
+ // working folder, which the session may read; the instruction carries no criteria, which the
280
+ // reviewer's skill reads only after its first passes), the files of the lot (the testwriter's by
281
+ // `isTesterFile`, then the others) and the circuit's reports since its last sheet (its proofs and
282
+ // checks). Its errors go to the log under its header line, its line to the register
283
+ // (scripts/registre.mjs --copie), and it is watched as a session in the envelope is. Its sheet
284
+ // is its verdict (`verdict` ACCEPTÉ or RENDU, its `constats`, each `bloquant` with the criterion
285
+ // or rule it holds, or `remarque`, its `question`, `ecarts`): the circuit writes it at the ticket
286
+ // for the human, under the author `relecteur`, its first word the verdict, each blocking finding
287
+ // under its heading with its recipient, then the remarks (`texteDuVerdict`).
288
+ // - A sheet of the reviewer with a `question` (what goes up to the owner, a real word of the
289
+ // language written in the code) stops the circuit at « besoin », whatever its verdict, before
290
+ // it commits or relaunches anyone, the question and the verdict as its report.
291
+ // - RENDU: the blocking findings alone send the lot back, a remark reaching only the ticket (a
292
+ // verdict without a blocking finding is ACCEPTÉ, the sheet's schema refusing any other). Each
293
+ // blocking finding names its recipient, `role` testwriter or developpeur and `composant` one of
294
+ // the title (a path or a package name). The circuit plays again the components the findings
295
+ // name, the provider first, and those alone (`suivant`: the final pass before the review
296
+ // carried every other one through): its testwriter → preuve → developpeur → controles when a
297
+ // finding names its testwriter, else its developpeur → controles, the findings for each role as
298
+ // the report of its session (those for the developpeur after the proof's report); then the
299
+ // final pass, restricted to what the fix changes and its dependents, and the reviewer again, on
300
+ // the difference: the lot it read before is kept (`lot` of its sheet's entry), as Gerrit keeps
301
+ // each patch set, and the next review receives the command of the difference between the two
302
+ // lots, the files of the lot that changed since, and the findings before it, which it judges
303
+ // lifted or not (`suivi`); the rest of the lot it does not read again (its skill, « La
304
+ // relecture bornée »).
305
+ // A blocking finding without a valid recipient, or one that puts its criterion in question
306
+ // (`critere_en_cause`), stops at « relecture-arret »; so does the third RENDU since
307
+ // the ticket's last ACCEPTÉ. The regulator settles.
308
+ // - ACCEPTÉ: the integration, once the whole final pass has played on the lot accepted (its tree
309
+ // is `verifie`); else that pass first, once, its red going to its owner's developpeur and the
310
+ // lot then to the reviewer again. The circuit builds the lot from the copy, as the regulator
311
+ // built it: the files the copy changes against its HEAD, the testwriter's (`isTesterFile`: test
312
+ // files and the shared test material) in one patch, the others in another, each file against
313
+ // HEAD and a new one against nothing (`git diff --no-index /dev/null`), the copy's index
314
+ // untouched; the message `<type>(<composants>): <sujet> (<ticket>)`, the type read from the
315
+ // ticket's type (bug fix, chore, docs, else feat), its body written once from the final state:
316
+ // the criteria of the plan, then each deviation the sheets of the developpeurs and of the
317
+ // reviewer name (`ecarts`), once by its subject, from the last sheet that carries it, with the
318
+ // trailer Co-Authored-By. It runs
319
+ // scripts/integration/integrer.mjs from the main tree, its output added to the log: 0 is the
320
+ // commit on agent/<ticket> (which the circuit names from git), the copy removed, the branch in
321
+ // the queue; 75 (« à repasser ») plays it once more, then stops at « a-repasser »; any other
322
+ // code is its refusal, which stops at « integration ».
323
+ // - Then the queue: scripts/integration/file.mjs from the main tree, waited for, its output added
324
+ // to the log (it brings the branch onto main, pushes it and reads the CI of the commit pushed).
325
+ // A queue already running exits 0 at once: the circuit waits while the branch is in the queue
326
+ // and the queue's lock names a live process. Then the state of the ticket: closed, the circuit
327
+ // ends at « integre », exit 0 (the queue's last word at the ticket named with it, a CI it could
328
+ // not read); reopened (out of the queue, or a red CI), it stops at « integration », the queue's
329
+ // last word as its reason; still in the queue, it stops at « a-repasser », the queue's reason.
330
+ // - The working directory is the ticket's, `.claude/worktrees/<ticket>.travail`: the instructions,
331
+ // results and errors of the sessions, the patches and message of the integration. It lives as
332
+ // long as the ticket, as the workspace of a pipeline: a circuit relaunched takes it up again, no
333
+ // sweep touches it, the queue removes it at the ticket's closure, and scripts/retirer-copie.sh
334
+ // with the copy of a ticket closed without integration. The results and progress of the passes
335
+ // live in the base of the run (scripts/vitest-config/base-de-passe.mjs), removed at its end.
336
+ // - The state is `.claude/worktrees/<ticket>.circuit.json`, written again at each step and at the
337
+ // start and end of each session: `{ etape, composant, tentative, code, raison, rapport, dernier,
338
+ // finale, boucles, seance, regulateur, questionnement, processus, composants, renvois, atteints,
339
+ // verifie, mene, reprise, journal }`; `processus` the process of the circuit that writes it; `questionnement` the questioning under way (`{ objet, tour, seance
340
+ // }`: the plan or a decision, its round, the questionneur's session), null otherwise;
341
+ // `etape`
342
+ // is the state's name of a numbered step of the process (scripts/etapes.mjs: testwriter 2.1,
343
+ // preuve 2.2, developpeur 2.3, controles 2.4, or 3 the final pass once `finale`, references
344
+ // 3.1, relecture 4,
345
+ // integration 5.1, file 5.2), or integre at the end; `raison` names the stop; `composants` the
346
+ // components of the title, in their order, which the board reads for the rank of `composant`;
347
+ // `boucles` the counts of the bounded loops of the step under way, by their key (`tests`, the
348
+ // testwriter's sessions with their proofs; `code`, the developpeur's with their checks;
349
+ // `relecture`, the reviews; `arret`, the pass of the same stop at the arbitrage;
350
+ // `questionnement`, the round of a questioning), null at a step without one;
351
+ // `dernier` is the last component of the run; `finale` whether the final pass has begun; `seance` the process group of
352
+ // the session that runs, null between two; `regulateur` the regulator's session on the stop
353
+ // (`{ seance, arret, code, rapport, faits, ticket, signature }`: its id, the stop it settles,
354
+ // its code and its reason, the stop's facts, the fingerprints of the description and of the
355
+ // stop), while it runs and after its question, null otherwise; `renvois` the components a
356
+ // RENDU, `--depuis` or a gesture of the table plays again, each with its first step and its
357
+ // findings, and under `--depuis` its context (`contexte`), null out of such a round;
358
+ // `atteints` the components the circuit has carried through, each on its own (its proof without a
359
+ // red for its developpeur, its developpeur's delivery, its green checks, every one once the final
360
+ // pass is green), whatever the title gains before it, kept from one run to the next, empty when a
361
+ // run starts over from the first component's testwriter; `verifie` the tree of the lot the last
362
+ // whole final pass green played, null before; `mene` the start (an ISO date) of the step the
363
+ // circuit leads itself while it runs, null otherwise; `reprise` the session of the testwriter or
364
+ // developpeur its relaunches resume, `{ agent, seance }`, null before any. `--reprendre` starts again at the step and component of the state, its report given to the
365
+ // next session, and goes on to its `dernier`; after any stop, the death of the circuit with its
366
+ // caller included. A session the state names that still runs is waited for first, and stopped
367
+ // when it stands still as long as a motionless session.
368
+ // - `--depuis <testwriter|developpeur> <composant>` (a path or a package name the title names) has
369
+ // the lot corrected by one component: it plays it from that agent, the text of `--contexte` on
370
+ // the line CONTEXTE of each agent of that component, its testwriter then its developpeur, as a
371
+ // retry of GitLab CI plays the jobs after it with the same variables: that component's steps,
372
+ // its developpeur launched by a red of its proof as without `--depuis`; then each component after
373
+ // it that the circuit has not carried through yet (`atteints`), its whole circuit, testwriter,
374
+ // proof, developpeur and checks; one it has carried through plays nothing, the final pass judges
375
+ // it; then the final pass, the reviewer and the integration. It goes with no `--reprendre`.
376
+ // - On a stop other than 0 and 21, the table of the stops, tried line by line in its order (`genre`, its
377
+ // gesture, the case of BPScript 320.3 it settles): the same stop (its name, step and component)
378
+ // come back after a RELANCE on the same fingerprint (the files the copy changes against its HEAD
379
+ // and the ticket's description, taken at each stop) ends the circuit on it, for the supervisor,
380
+ // its decisions in its reason; « à repasser » ends at 75; reds to settle go to the regulator;
381
+ // a red of another component waits for its checks, or at the final pass goes to its developpeur
382
+ // (above); a developpeur's sending plays its testwriter (above);
383
+ // on `--reponse`, a component the title gained since the question plays from its testwriter, the
384
+ // stop as its context (the answer is in the plan), with no regulator resumed. Each gesture the
385
+ // circuit applies itself is written at the ticket, `Circuit · <composant> · arbitrage : <genre>
386
+ // → <geste>`, and counts as a RELANCE on its stop.
387
+ // - On a stop the table does not settle, the regulator: a session out of the envelope, as the
388
+ // reviewer's, claude itself (`claude -p --agent regulateur --session-id <uuid>`) in the
389
+ // ticket's copy (the main tree once the integration removed it), on Sonnet, under the rights
390
+ // and the context of its role (scripts/droits.mjs: the commands its skill names, the summary of
391
+ // the state by scripts/etat-du-circuit.mjs, the reads of a precedent by bd and of the copy by
392
+ // git, the reads of the log and the proofs, the log searched in pieces; no write at the ticket),
393
+ // its instruction from the third template of consigne-agent.md: the ticket, the stop's name and
394
+ // code, its step, component and reason, the paths of the state and the log, and the plan it
395
+ // judges on, the ticket's description, where a decision of the owner is poured. The state
396
+ // names its session (`regulateur`) while it runs. Its sheet is its decision (`decision`:
397
+ // « RELANCE --reprendre » with its `contexte`, « RELANCE --depuis » with in `depuis` one or more
398
+ // components of the title, each with its agent and its `contexte`, or « MONTE » with its
399
+ // `question`; its `faits`, the facts of the state it rests on; for a RELANCE,
400
+ // its `arbitrage`, what it settled alone with its source, and its `critere`, the criterion it
401
+ // adds). A
402
+ // fresh session's decision is questioned first, as the plan is, the regulator answering in its
403
+ // own session, resumed with the tenth template (its `reponses`, and the decision it ends on,
404
+ // changed or kept, the one the circuit checks and applies); a DÉSACCORD, or a fourth round still
405
+ // questioned, makes of the exchange the regulator's question, the stop « question », its session
406
+ // kept; a decision on the supervisor's answer is not questioned. The
407
+ // circuit checks it on the state before it applies it, an admission control (refusDe), each
408
+ // component of a --depuis judged: a component out of the title or named twice, a step the state
409
+ // does not hold, a developpeur whose testwriter has not run, a decision other than a RELANCE from a testwriter after the ticket's description changed (a
410
+ // criterion added: the table's `critere-neuf`), a fact the state proves false (au-titre,
411
+ // hors-titre, rouge-de, sans-changement, sous-charge), or the same RELANCE as the one before on
412
+ // the same stop with no fact the one before did not have, an `arbitrage` or a `critere` on a
413
+ // MONTE, or a `critere` that does not go to a testwriter first, is refused: written at the ticket
414
+ // (`Circuit · <composant> · regulateur : décision refusée — <raison>`), the same session resumed
415
+ // once with the reason (the seventh template); a second refusal ends the circuit on the stop,
416
+ // both reasons joined to its own. The circuit writes a decision admitted at the ticket
417
+ // (`Circuit · <composant> · regulateur : …`), then its arbitrage (`Circuit · <composant> ·
418
+ // regulateur : arbitrage — <texte>`, which scripts/consommateurs-arbitres.mjs and the
419
+ // integration read as settled) and its criterion, numbered after the last one at the end of the
420
+ // description (`bd update -d`). RELANCE runs the circuit again in the same process, as the
421
+ // option it names, the context as `--contexte`, a --depuis of several components as one of each
422
+ // in a single run: each played again in the order of the title, from its agent with its context
423
+ // (`renvois`), then the components after them the circuit has not carried through, then one final
424
+ // pass, as GitLab's « Retry failed jobs » retries every failed job in one go; MONTE ends the
425
+ // circuit at « question », the question as its reason, the regulator's session kept in the
426
+ // state. `--reponse <texte>` first
427
+ // pours the answer into the plan, as an RFC amended or an ADR, the plan being what every agent
428
+ // reads and the exchange its history: a fresh session of the technical referent, out of the
429
+ // envelope (the twelfth template: the question, the answer, the plan), changes the plan (a
430
+ // criterion added, removed or made precise, its title) or keeps it; the circuit writes the plan
431
+ // (`bd update`) and, the plan changed, surveys it again and has it questioned, as at 1.2, the
432
+ // referent answering in its own session, before anything else; a plan the answer leaves as it
433
+ // was is said at the ticket, with no questioning. Then it resumes the regulator's session
434
+ // (`claude -p --resume <id>`, the fourth template: the supervisor's answer, which may carry the
435
+ // owner's, and the plan as it leaves it) and applies the decision it gives the same way; without
436
+ // a session kept
437
+ // after a question, nor a stop « composants-manquants » or « questionnement » it answers, the
438
+ // call is refused. A session without a sheet that conforms is resumed
439
+ // once, as any session; a second one, or
440
+ // a session that cannot end (a launch that fails, a motionless session), ends the circuit on the
441
+ // stop it settled, the reason joined to its own.
442
+ // - `--decision <texte>`, a decision of the owner given in session, out of any stop « question »
443
+ // (at « question », « questionnement » or « composants-manquants » the call is refused: the
444
+ // stop waits for `--reponse`; so is it once the lot has reached the integration), takes the same
445
+ // path into the plan, as GitLab CI cancels a pipeline a new one makes redundant and Zuul the
446
+ // jobs of a change superseded: a circuit running on the ticket (the state's `processus`, alive)
447
+ // stops first, SIGTERM then SIGKILL, and so does the session it led (`seance`), said at the
448
+ // ticket (`Circuit · <composant> · decision : …`); then the technical referent pours the
449
+ // decision into the plan (the twelfth template, its question « aucune »), the circuit writes it,
450
+ // and a plan changed is surveyed again and questioned. The run resumes where the state left it,
451
+ // as `--reprendre`: each component the change reaches that it had passed (carried through,
452
+ // before its component, or all of them at the final pass and the review), and its own component
453
+ // past its testwriter when the change reaches it, plays again from its testwriter (`renvois`,
454
+ // the plan changed as its report, never the decision), the run starting at the first of them,
455
+ // its own component then resuming at its step; the components the change reaches are those the
456
+ // survey of the components (scripts/composants-affectes.mjs) finds in the lines it adds or
457
+ // removes, with each one the title gained, all of the title when the survey finds none. A
458
+ // decision that leaves the plan as it was is said at the ticket, and the run resumes as it was.
459
+ // Without a state, the run starts on the plan it leaves. The third RELANCE in a row on one stop (its
460
+ // name, step and component) is the last: when that stop comes again, the circuit ends on it, the
461
+ // three decisions in its reason, without a regulator.
462
+ // - At each step, a short comment at the ticket: `Circuit · <composant> · <étape> : <résumé>`.
463
+ // A proof writes there its reds and what refuses it; its whole report, the justified greens with
464
+ // their reasons, goes to `.claude/worktrees/<ticket>.preuve-<n>.md`, which the comment names.
465
+ // - The commands of the circuit (the proof, the checks, the integration) are added to the log of the ticket,
466
+ // `.claude/worktrees/<ticket>.log`, next to the sessions, each under its header line.
467
+ // - Each step the circuit leads itself, with no agent (the proof, the checks and the final pass,
468
+ // the integration, the queue), writes at its end, whatever its outcome, a line to the register
469
+ // of finished agents (scripts/registre.mjs, `ligneDEtape`): the role `circuit`, the ticket, the
470
+ // step and its component, its start and end, its working time, no tokens. The board counts it,
471
+ // and the step under way (`mene` in the state), in the ticket's working time.
472
+ // - At each of its ends (« integre », or a stop it hands to the supervisor), the circuit writes the
473
+ // ticket's bilan (scripts/bilan.mjs): its figures, measured on the register, its results and its
474
+ // state, kept in `.claude/worktrees/<ticket>.bilan.json` and written at the ticket
475
+ // in one short comment, `Circuit · <composant> · bilan : …`, compared with the bilan before it in
476
+ // the project.
477
+ // - Exit codes: 0 at « integre »; 2 a malformed call; a stop, by its name (ARRETS):
478
+ // 10 lancement (lancer.sh stopped before the session), 11 bloqué (a motionless transcript),
479
+ // 12 sans-fiche (twice no sheet that conforms), 13 preuve (the third proof fails), 14 controles
480
+ // (the third developpeur of a component's checks or of the final pass leaves a red check, a red
481
+ // of the final pass no component of the title owns, or reds to settle),
482
+ // 15 sans-livraison (a developpeur session that changes no file of its component; the first
483
+ // session of a `--reprendre` whose component already holds changes goes on to the checks; or
484
+ // the developpeur's same sending to a testwriter twice in a row, no green checks between them),
485
+ // 16 relecture-arret (a finding without a valid recipient, one on a criterion, the third RENDU),
486
+ // 17 integration (the integration script refuses the lot, or the queue reopens the ticket),
487
+ // 18 question (the regulator's question for the supervisor, `--reponse` resumes it),
488
+ // 19 hors-portee (a testwriter or developpeur whose sheet says BLOQUÉ, without a sending to a
489
+ // testwriter), 20 besoin (a testwriter or developpeur whose sheet says BESOIN, a reviewer whose sheet
490
+ // carries a `question`),
491
+ // 21 composants-manquants (a component the survey of the ticket finds and its title misses),
492
+ // 22 questionnement (the questioning of the plan ends on a disagreement, or its fourth round is
493
+ // still questioned),
494
+ // 75 a-repasser (a check or the integration still at 75 once played again, or the branch still
495
+ // in the queue).
496
+ import { spawn, spawnSync } from 'node:child_process';
497
+ import { createHash, randomUUID } from 'node:crypto';
498
+ import {
499
+ closeSync,
500
+ copyFileSync,
501
+ existsSync,
502
+ openSync,
503
+ mkdirSync,
504
+ mkdtempSync,
505
+ readdirSync,
506
+ readFileSync,
507
+ renameSync,
508
+ rmSync,
509
+ statSync,
510
+ appendFileSync,
511
+ writeFileSync,
512
+ } from 'node:fs';
513
+ import os from 'node:os';
514
+ import path from 'node:path';
515
+ import { pathToFileURL } from 'node:url';
516
+ import { stripVTControlCharacters } from 'node:util';
517
+ import { ecrireLeBilan } from './bilan.mjs';
518
+ import { DEPUIS_LE_VERT } from './dernier-vert.mjs';
519
+ import { releve as releveDuTexte, repondre, verifierLeTitre } from './composants-affectes.mjs';
520
+ import { optionsDuRole } from './droits.mjs';
521
+ import { matieres, NOM_DE_TEST } from './consommateurs.mjs';
522
+ import { VARIABLE as VARIABLE_ENVELOPPE } from './enveloppe/composant.mjs';
523
+ import {
524
+ ARRETS,
525
+ AUTO,
526
+ boucle,
527
+ bouclesDe,
528
+ etapeDuCircuit,
529
+ gesteDe,
530
+ libelle,
531
+ nomDuRole as enMots,
532
+ QUESTIONNEUR,
533
+ REGULATEUR,
534
+ SUITE,
535
+ TABLE_DES_ARRETS,
536
+ } from './etapes.mjs';
537
+ import { paquets } from './paquets.mjs';
538
+ import { ecrire as ecrireAuRegistre, ligneDEtape, transcriptionsDeLaCopie } from './registre.mjs';
539
+ import {
540
+ lireFiche,
541
+ lireRapportsVitest,
542
+ lireResumes,
543
+ lireSarif,
544
+ RESULTATS,
545
+ schemaDeFiche,
546
+ } from './resultats.mjs';
547
+ import { AUTEUR_DE_LA_FILE } from './integration/file.mjs';
548
+ import { dossierCommun, lireEntrees } from './integration/file-attente.mjs';
549
+ import { AUTEUR_DU_VERDICT } from './integration/verdict.mjs';
550
+ import { attendreUnPeu } from './verrou-de-fichier.mjs';
551
+ import { AttenteDeLaMachine, avecLaMachine } from './verrou-de-machine.mjs';
552
+ import { garderLeTotal, ouvrirAvancement } from './avancement.mjs';
553
+ import { AVANCEMENT } from './vitest-config/avancement.mjs';
554
+ import { NOMS_DES_GARDES } from './gardes-du-cadre.mjs';
555
+ import { isTesterFile } from './verrous/verrou.mjs';
556
+ import { A_REPASSER } from './vitest-config/pression.mjs';
557
+ import { ouvrirBase, vivant } from './vitest-config/base-de-passe.mjs';
558
+ import { GREFFON } from './cadre.mjs';
559
+
560
+ // The time a session's transcripts may stand still before the circuit stops it.
561
+ export const IMMOBILE_MS = 20 * 60 * 1000;
562
+ // The exit code of each stop, from the list of the steps and stops.
563
+ export { ARRETS };
564
+ const CODE_USAGE = 2;
565
+ // The bounded loops, each declared with its step (scripts/etapes.mjs, `boucles`): the testwriter is
566
+ // relaunched twice at most after a failed proof (three sessions); the developpeur has three
567
+ // sessions, the last on the strong model; the third RENDU of one ticket stops the circuit; the
568
+ // third RELANCE in a row on one stop is the last; the fourth round of questioning still questioned
569
+ // goes up to the supervisor.
570
+ const RELANCES_TESTWRITER = boucle('tests').max - 1;
571
+ const SEANCES_DEVELOPPEUR = boucle('code').max;
572
+ const RELECTURES = boucle('relecture').max;
573
+ const RELANCES_DU_REGULATEUR = boucle('arret').max;
574
+ const TOURS = boucle('questionnement').max;
575
+ // The author who answers for a ticket's plan to the questionneur.
576
+ const REFERENT = 'referent-technique';
577
+ // The roles a finding of the reviewer may name: the agents of the steps run for each component.
578
+ const DESTINATAIRES = SUITE.filter(e => e.parComposant)
579
+ .flatMap(e => e.qui)
580
+ .filter(q => q !== AUTO);
581
+ // The steps of the circuit's state, read from the list of the steps.
582
+ const etatDe = n => SUITE.find(e => e.numero === n).etat;
583
+ const [TESTS, PREUVE, CODE, CONTROLES, REFERENCES, RELECTURE, INTEGRATION, EN_FILE] = [
584
+ '2.1',
585
+ '2.2',
586
+ '2.3',
587
+ '2.4',
588
+ '3.1',
589
+ '4',
590
+ '5.1',
591
+ '5.2',
592
+ ].map(etatDe);
593
+ // The variable in which bd reads the author of a comment (`bd --help`: « default: $BEADS_ACTOR,
594
+ // git user.name, $USER »); the author of the circuit's own lines; the author of a session of
595
+ // <role> on <composant>: `testwriter <composant>`, `developpeur <composant>`, else the role alone.
596
+ const ACTEUR = 'BEADS_ACTOR';
597
+ const AUTEUR_DU_CIRCUIT = 'circuit';
598
+ const auteur = (role, composant) => (DESTINATAIRES.includes(role) ? `${role} ${composant}` : role);
599
+ // The sessions out of the envelope (the reviewer on Opus, the regulator on Sonnet), each in the
600
+ // ticket's copy, the main tree once the integration removed it, under the rights and the context
601
+ // of its role (scripts/droits.mjs: the commands its skill names, the regulator's reads of the
602
+ // ticket's log and proofs, the refusal of a change of its author at the ticket), without a
603
+ // prompt: any call its rights do not permit is refused.
604
+ const horsDeLEnveloppe = (ctx, role, modele) => [
605
+ '--model',
606
+ modele,
607
+ '--permission-mode',
608
+ 'dontAsk',
609
+ ...optionsDuRole(role, {
610
+ racine: ctx.racine,
611
+ copie: existsSync(ctx.copie) ? ctx.copie : ctx.racine,
612
+ ticket: ctx.ticket,
613
+ journal: ctx.journal,
614
+ preuves: `${ctx.copie}.preuve-*.md`,
615
+ }),
616
+ ];
617
+ const FORT = ['--modele', 'opus', '--effort', 'high'];
618
+ const PREFIXE = 'Circuit · ';
619
+ const TICKET_SH = path.join(import.meta.dirname, 'ticket.sh');
620
+ // The steps the circuit leads itself, with no agent: each one counts in the ticket's working time.
621
+ const MENEES = new Set(SUITE.filter(e => e.qui.includes(AUTO) && e.etat).map(e => e.etat));
622
+
623
+ const ici = import.meta.dirname;
624
+ const LANCEUR = path.join(ici, 'enveloppe', 'lancer.sh');
625
+ const INTEGRER = path.join(ici, 'integration', 'integrer.mjs');
626
+ const FILE = path.join(ici, 'integration', 'file.mjs');
627
+ const REGISTRE = path.join(ici, 'registre.mjs');
628
+ // The templates of the instructions, in the skill of the supervisor, which the frame's plugin
629
+ // carries, read at the plugin's root (scripts/cadre.mjs).
630
+ const GABARIT = path.join(GREFFON, 'skills', 'pitmaster', 'references', 'consigne-agent.md');
631
+
632
+ // A stop of the circuit: its name (a key of ARRETS) and its report; for a stop of the checks, its
633
+ // reds read in the structured results (`{ composant, texte, detail }`) and its motive, the report
634
+ // without their list, which the regulator receives apart; the outcome of the step it stops, when
635
+ // it has one (a proof's), which the bilan reads; and the facts the table of the stops and the
636
+ // admission of the regulator's decision read (`faits`: `{ genre, composant, rouges,
637
+ // aRepasser }`, its kind in the table, the component its gesture plays, the components or
638
+ // packages that own its reds, whether a command of it was at 75).
639
+ class Arret extends Error {
640
+ constructor(nom, rapport, { rouges = null, motif = null, resultat = null, faits = {} } = {}) {
641
+ super(nom);
642
+ this.nom = nom;
643
+ this.rapport = rapport;
644
+ this.rouges = rouges;
645
+ this.motif = motif;
646
+ this.resultat = resultat;
647
+ this.faits = faits;
648
+ }
649
+ }
650
+
651
+ // A malformed call: its message.
652
+ class Usage extends Error {}
653
+
654
+ const sansCouleurs = t => stripVTControlCharacters(String(t ?? ''));
655
+ const premiereLigne = t => sansCouleurs(t).trim().split('\n')[0].slice(0, 300);
656
+ const fin = (t, n) => sansCouleurs(t).split('\n').slice(-n).join('\n');
657
+ const enUneLigne = t =>
658
+ sansCouleurs(t)
659
+ .split('\n')
660
+ .map(l => l.trim())
661
+ .filter(Boolean)
662
+ .join(' ')
663
+ .slice(0, 300);
664
+
665
+ // The options of the command line, or a Usage.
666
+ function lireArguments(argv) {
667
+ const o = {
668
+ ticket: null,
669
+ reprendre: false,
670
+ contexte: null,
671
+ depuis: null,
672
+ reponse: null,
673
+ decision: null,
674
+ };
675
+ for (let i = 0; i < argv.length; i++) {
676
+ const a = argv[i];
677
+ if (a === '--reprendre') o.reprendre = true;
678
+ else if (a === '--reponse') {
679
+ if (i + 1 >= argv.length) throw new Usage('--reponse attend la réponse du superviseur.');
680
+ o.reponse = argv[++i];
681
+ } else if (a === '--decision') {
682
+ if (i + 1 >= argv.length) throw new Usage('--decision attend la décision du responsable.');
683
+ o.decision = argv[++i];
684
+ } else if (a === '--depuis') {
685
+ const [etape, composant] = argv.slice(i + 1, i + 3);
686
+ if (!['testwriter', 'developpeur'].includes(etape))
687
+ throw new Usage('--depuis attend testwriter ou developpeur, puis un composant.');
688
+ if (!composant || composant.startsWith('-'))
689
+ throw new Usage('--depuis attend son composant.');
690
+ o.depuis = { etape, composant };
691
+ i += 2;
692
+ } else if (a === '--contexte') {
693
+ if (i + 1 >= argv.length) throw new Usage('--contexte attend son texte.');
694
+ o.contexte = argv[++i];
695
+ } else if (a.startsWith('-')) throw new Usage(`option inconnue : ${a}`);
696
+ else if (o.ticket) throw new Usage(`un seul ticket : ${o.ticket}, puis ${a}.`);
697
+ else o.ticket = a;
698
+ }
699
+ if (!o.ticket) throw new Usage('le ticket manque.');
700
+ if (o.depuis && o.reprendre) throw new Usage('--depuis et --reprendre ne vont pas ensemble.');
701
+ if (o.reponse !== null && (o.reprendre || o.depuis || o.contexte !== null))
702
+ throw new Usage('--reponse va seule : le régulateur rend la décision.');
703
+ if (o.decision !== null && (o.reprendre || o.depuis || o.contexte !== null || o.reponse !== null))
704
+ throw new Usage('--decision va seule : le circuit reprend là où il en était.');
705
+ return o;
706
+ }
707
+
708
+ // The main tree, from git's common directory: run from a copy, the circuit still finds the others.
709
+ function arbrePrincipal() {
710
+ const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
711
+ encoding: 'utf8',
712
+ });
713
+ if (r.status !== 0) throw new Usage("le dossier courant n'est pas dans un dépôt git.");
714
+ return path.dirname(r.stdout.trim());
715
+ }
716
+
717
+ // The context of one run: the paths of the ticket, its components, how the circuit speaks to bd.
718
+ function contexte(o) {
719
+ const racine = arbrePrincipal();
720
+ const copie = path.join(racine, '.claude', 'worktrees', o.ticket);
721
+ // The comments of a long ticket pass the 1 MiB that a child process returns by default. bd runs
722
+ // under the author <qui>; `bd` under the circuit's.
723
+ const bdSous = (qui, ...a) =>
724
+ spawnSync('bd', a, {
725
+ cwd: racine,
726
+ encoding: 'utf8',
727
+ maxBuffer: 256 * 1024 * 1024,
728
+ env: { ...process.env, [ACTEUR]: qui },
729
+ });
730
+ const bd = (...a) => bdSous(AUTEUR_DU_CIRCUIT, ...a);
731
+ const show = bd('show', o.ticket, '--json');
732
+ if (show.status !== 0) throw new Usage(`ticket ${o.ticket} illisible : ${show.stderr}`);
733
+ const t = JSON.parse(show.stdout)[0];
734
+ const base = existsSync(copie) ? copie : racine;
735
+ const liste = paquets(base);
736
+ const resoudre = nom => {
737
+ if (nom === '.') return '.';
738
+ const norme = path.normalize(nom).endsWith('/')
739
+ ? path.normalize(nom).slice(0, -1)
740
+ : path.normalize(nom);
741
+ if (liste.some(p => p.dossier === norme)) return norme;
742
+ const parNom = liste.find(p => p.pkg.name === nom);
743
+ if (parNom) return parNom.dossier;
744
+ if (!norme.startsWith('..') && existsSync(path.join(base, norme))) return norme;
745
+ throw new Usage(
746
+ `le composant « ${nom} » du titre ne nomme ni un paquet ni un chemin du dépôt.`
747
+ );
748
+ };
749
+ // The subject and the components of the title <titre>, « numéro — sujet — composants », or a
750
+ // Usage.
751
+ const duTitre = titre => {
752
+ const parts = String(titre ?? '').split(' — ');
753
+ if (parts.length < 2) throw new Usage(`le titre de ${o.ticket} ne nomme pas ses composants.`);
754
+ return {
755
+ sujet: parts.length > 2 ? parts.slice(1, -1).join(' — ') : parts[0],
756
+ composants: parts
757
+ .at(-1)
758
+ .split(',')
759
+ .map(c => c.trim())
760
+ .filter(Boolean)
761
+ .map(resoudre),
762
+ };
763
+ };
764
+ const { sujet, composants } = duTitre(t?.title);
765
+ if (o.depuis) {
766
+ const c = composants.includes(o.depuis.composant)
767
+ ? o.depuis.composant
768
+ : composants.find(x => {
769
+ try {
770
+ return resoudre(o.depuis.composant) === x;
771
+ } catch {
772
+ return false;
773
+ }
774
+ });
775
+ if (!c) throw new Usage(`--depuis : ${o.depuis.composant} n'est pas un composant du titre.`);
776
+ o.depuis.composant = c;
777
+ }
778
+ // The working directory of the circuit is the ticket's, `<copie>.travail` beside its copy: what
779
+ // the circuit hands its sessions (their instructions, results and errors, the reviewer's
780
+ // statements, the integration's patches and message), which a session out of the envelope reads
781
+ // even when its circuit is killed. As the workspace of a pipeline (Jenkins, GitLab), it lives as
782
+ // long as the ticket: no sweep touches it, a circuit relaunched takes it up again, numbering its
783
+ // files after those it finds, and the queue removes it at the ticket's closure, or the removal of
784
+ // the copy of a ticket closed without integration (scripts/retirer-copie.sh). The results and
785
+ // the progress of the circuit's passes live in the base of its run
786
+ // (scripts/vitest-config/base-de-passe.mjs), removed at its end, swept when the run is killed.
787
+ const tmp = `${copie}.travail`;
788
+ mkdirSync(tmp, { recursive: true });
789
+ const numero = Math.max(
790
+ 0,
791
+ ...readdirSync(tmp).map(n => Number(/-(\d+)(?:\.[a-z]+)?$/.exec(n)?.[1] ?? 0))
792
+ );
793
+ const baseDuRun = ouvrirBase('circuit');
794
+ return {
795
+ ...o,
796
+ // The components a relaunch --depuis plays again, each with its step and its context.
797
+ depuis: o.depuis ? [{ ...o.depuis, contexte: o.contexte }] : null,
798
+ racine,
799
+ copie,
800
+ bd,
801
+ bdSous,
802
+ resoudre,
803
+ duTitre,
804
+ type: String(t.issue_type ?? ''),
805
+ sujet,
806
+ composants,
807
+ etat: `${copie}.circuit.json`,
808
+ journal: `${copie}.log`,
809
+ fin: `${copie}.fin`,
810
+ resultat: `${copie}.resultat.json`,
811
+ fiches: `${copie}.fiches.jsonl`,
812
+ transcriptions: transcriptionsDeLaCopie(copie, os.homedir()),
813
+ immobileMs: Number(process.env.GRILLHOUSE_CIRCUIT_IMMOBILE_MS) || IMMOBILE_MS,
814
+ base: baseDuRun,
815
+ tmp,
816
+ numero,
817
+ // The regulator's RELANCE decisions in a row on one stop.
818
+ relances: [],
819
+ };
820
+ }
821
+
822
+ // Every comment at the ticket, in their order, `{ author, text }` as Beads gives them; a failed
823
+ // read throws.
824
+ function lesCommentaires(ctx) {
825
+ const r = ctx.bd('comments', ctx.ticket, '--json');
826
+ if (r.status !== 0) {
827
+ throw new Error(
828
+ `commentaires de ${ctx.ticket} illisibles : ${r.error?.message ?? r.stderr ?? ''}`.trim()
829
+ );
830
+ }
831
+ return (JSON.parse(r.stdout || '[]') ?? []).map(c => ({
832
+ author: String(c.author ?? ''),
833
+ text: String(c.text ?? ''),
834
+ }));
835
+ }
836
+
837
+ // The ticket's description (its criteria), notes and title as Beads holds them at this moment, read
838
+ // at the launch of each session: `{ description, notes, titre }`; a failed read throws.
839
+ function leTicket(ctx) {
840
+ const r = ctx.bd('show', ctx.ticket, '--json');
841
+ if (r.status !== 0) {
842
+ throw new Error(
843
+ `ticket ${ctx.ticket} illisible : ${r.error?.message ?? r.stderr ?? ''}`.trim()
844
+ );
845
+ }
846
+ const t = JSON.parse(r.stdout || '[]')[0] ?? {};
847
+ return {
848
+ description: String(t.description ?? ''),
849
+ notes: String(t.notes ?? ''),
850
+ titre: String(t.title ?? ''),
851
+ };
852
+ }
853
+
854
+ // The comments of the agents at the ticket, `{ author, text }`: every comment but the circuit's
855
+ // own. The circuit reads none of them: it hands an agent's last one to the next session of its
856
+ // author, and names it in a stop.
857
+ const passations = ctx => lesCommentaires(ctx).filter(c => c.author !== AUTEUR_DU_CIRCUIT);
858
+
859
+ // The last handover at the ticket of the author <qui>, or null.
860
+ const derniereDe = (ctx, qui) =>
861
+ passations(ctx)
862
+ .filter(c => c.author === qui)
863
+ .at(-1) ?? null;
864
+
865
+ // The sheets of the ticket's sessions and the circuit's reports, in their order
866
+ // (`<ticket>.fiches.jsonl`, beside the copy): `{ date, role, composant, fiche }` for the sheet of
867
+ // a session, `{ date, role: 'circuit', etape, composant, rapport }` for the report of a proof or of
868
+ // checks, which the reviewer receives.
869
+ function lesFiches(ctx) {
870
+ return lire(ctx.fiches)
871
+ .split('\n')
872
+ .filter(Boolean)
873
+ .flatMap(l => {
874
+ try {
875
+ return [JSON.parse(l)];
876
+ } catch {
877
+ return [];
878
+ }
879
+ });
880
+ }
881
+ const inscrire = (ctx, entree) =>
882
+ appendFileSync(ctx.fiches, `${JSON.stringify({ date: new Date().toISOString(), ...entree })}\n`);
883
+
884
+ // The sheets of <role> on <composant> (every component when null), in their order.
885
+ const fichesDe = (ctx, role, composant = null) =>
886
+ lesFiches(ctx)
887
+ .filter(e => e.role === role && (composant === null || e.composant === composant))
888
+ .map(e => e.fiche);
889
+
890
+ // The step of the circuit's state <etat> in words, « 2.3 Code » (scripts/etapes.mjs), or its
891
+ // `etape` when the list holds none.
892
+ const ouDansLeCircuit = etat => {
893
+ const e = etapeDuCircuit(etat);
894
+ return e ? libelle(e) : etat.etape;
895
+ };
896
+
897
+ // One line of the circuit's history at the ticket.
898
+ function commenter(ctx, composant, etape, texte) {
899
+ const r = ctx.bd('comments', 'add', ctx.ticket, `${PREFIXE}${composant} · ${etape} : ${texte}`);
900
+ if (r.status !== 0) process.stderr.write(`⚠️ commentaire non écrit au ticket : ${r.stderr}\n`);
901
+ }
902
+
903
+ // The report of a proof or of checks: its line at the ticket, and its report kept for the
904
+ // reviewer, <rapport> when the comment carries only its summary, beside its outcome <resultat>,
905
+ // which the bilan reads (scripts/bilan.mjs): for a proof `{ ok, rouges, justifies, sans, casses,
906
+ // anciens, vide }`, for checks `{ issue, finale }`.
907
+ function ecrireRapport(ctx, composant, etape, texte, resultat, rapport = texte) {
908
+ commenter(ctx, composant, etape, texte);
909
+ inscrire(ctx, {
910
+ role: AUTEUR_DU_CIRCUIT,
911
+ etape,
912
+ composant,
913
+ resultat,
914
+ rapport: `${PREFIXE}${composant} · ${etape} : ${rapport}`,
915
+ });
916
+ }
917
+
918
+ // The state of the run, written whole (`seance` and `regulateur` null and `finale` false unless <etat>
919
+ // sets them, `processus`, `renvois`, `atteints`, `verifie` and `reprise` the run's), kept as the
920
+ // run's current state.
921
+ function ecrireEtat(ctx, etat) {
922
+ ctx.courant = {
923
+ finale: false,
924
+ seance: null,
925
+ regulateur: null,
926
+ questionnement: null,
927
+ mene: null,
928
+ avancement: null,
929
+ releve: null,
930
+ ...etat,
931
+ processus: process.pid,
932
+ composants: ctx.composants,
933
+ renvois: ctx.renvois ?? null,
934
+ atteints: ctx.atteints ?? [],
935
+ verifie: ctx.verifie ?? null,
936
+ reprise: ctx.reprise ?? null,
937
+ };
938
+ const provisoire = `${ctx.etat}.${process.pid}`;
939
+ writeFileSync(
940
+ provisoire,
941
+ `${JSON.stringify({ ...ctx.courant, journal: ctx.journal }, null, 2)}\n`
942
+ );
943
+ renameSync(provisoire, ctx.etat);
944
+ }
945
+
946
+ // The register's line of the step <etape> of <composant> the circuit led itself from <debut> (an
947
+ // ISO date) to now; a register it cannot write leaves the circuit going.
948
+ function inscrireEtape(ctx, { etape, composant, debut }) {
949
+ try {
950
+ ecrireAuRegistre(
951
+ ligneDEtape({
952
+ projet: ctx.racine,
953
+ ticket: ctx.ticket,
954
+ etape,
955
+ composant,
956
+ debut,
957
+ fin: new Date().toISOString(),
958
+ })
959
+ );
960
+ } catch (e) {
961
+ process.stderr.write(`⚠️ étape ${etape} non écrite au registre : ${e.message}\n`);
962
+ }
963
+ }
964
+
965
+ const entete = (ctx, etape, composant) =>
966
+ appendFileSync(
967
+ ctx.journal,
968
+ `=== ${new Date().toISOString()} · circuit · ${etape} · ${composant} ===\n`
969
+ );
970
+
971
+ // The latest moment a transcript of the copy moved, or 0.
972
+ function derniereTrace(dossier) {
973
+ if (!existsSync(dossier)) return 0;
974
+ let m = 0;
975
+ for (const f of readdirSync(dossier, { recursive: true })) {
976
+ if (!String(f).endsWith('.jsonl')) continue;
977
+ try {
978
+ m = Math.max(m, statSync(path.join(dossier, String(f))).mtimeMs);
979
+ } catch {
980
+ // A transcript removed meanwhile does not move.
981
+ }
982
+ }
983
+ return m;
984
+ }
985
+
986
+ const mtime = f => {
987
+ try {
988
+ return statSync(f).mtimeMs;
989
+ } catch {
990
+ return 0;
991
+ }
992
+ };
993
+
994
+ // One launch of a session: `{ code, seance, bloque, erreur, sortie }`, `seance` the session's
995
+ // exit code (from lancer.sh's end file for a session in the envelope), null when the launch
996
+ // stopped before it, `sortie` claude's JSON result, its sheet in `structured_output`. Every
997
+ // session runs with the author of <role> on <composant> (BEADS_ACTOR). A session in the envelope
998
+ // opens under the id <ouvre> (lancer.sh --seance), or resumes the session <reprend> (lancer.sh
999
+ // --reprise); its result is the file lancer.sh writes (`<ticket>.resultat.json`).
1000
+ // A session <horsEnveloppe> (the reviewer's, the regulator's) is claude itself, in the ticket's
1001
+ // copy (the main tree once the copy is gone), under <droits> (`horsDeLEnveloppe`), the
1002
+ // schema of its role's sheet and the options <session> names (its id, or the session it
1003
+ // resumes), its instruction on its standard input and its result on its standard output, added
1004
+ // to the log under its header line, then its line written to the register, as lancer.sh does.
1005
+ // Its launch is kept with the circuit's results (`lancement`: its role and its session id), which
1006
+ // the bilan reads to count the sessions the circuit launched (scripts/bilan.mjs).
1007
+ function lancer(
1008
+ ctx,
1009
+ role,
1010
+ composant,
1011
+ consigne,
1012
+ { fort, horsEnveloppe, droits = [], session = [], ouvre, reprend } = {}
1013
+ ) {
1014
+ rmSync(ctx.fin, { force: true });
1015
+ rmSync(ctx.resultat, { force: true });
1016
+ const id = ouvre ?? reprend ?? session[1] ?? null;
1017
+ if (id) {
1018
+ inscrire(ctx, { role: AUTEUR_DU_CIRCUIT, composant, lancement: { role, seance: id } });
1019
+ }
1020
+ const fichier = path.join(ctx.tmp, `consigne-${++ctx.numero}.md`);
1021
+ writeFileSync(fichier, consigne);
1022
+ // A file, not a pipe: a session whose circuit is dead writes its errors without breaking.
1023
+ const erreurs = path.join(ctx.tmp, `erreurs-${ctx.numero}.txt`);
1024
+ let commande;
1025
+ let fds;
1026
+ // The result of a session out of the envelope.
1027
+ const reponse = path.join(ctx.tmp, `reponse-${ctx.numero}.json`);
1028
+ if (horsEnveloppe) {
1029
+ entete(ctx, role, composant);
1030
+ fds = [openSync(fichier, 'r'), openSync(reponse, 'w'), openSync(ctx.journal, 'a')];
1031
+ const fiche = ['--output-format', 'json', '--json-schema', JSON.stringify(schemaDeFiche(role))];
1032
+ commande = {
1033
+ cmd: process.env.CLAUDE_BIN || 'claude',
1034
+ args: ['-p', '--agent', role, ...fiche, ...droits, '--add-dir', ctx.tmp, ...session],
1035
+ cwd: existsSync(ctx.copie) ? ctx.copie : ctx.racine,
1036
+ };
1037
+ } else {
1038
+ fds = ['ignore', 'ignore', openSync(erreurs, 'w')];
1039
+ commande = {
1040
+ cmd: 'bash',
1041
+ args: [
1042
+ LANCEUR,
1043
+ ...(fort ? FORT : []),
1044
+ ...(ouvre ? ['--seance', ouvre] : []),
1045
+ ...(reprend ? ['--reprise', reprend] : []),
1046
+ ctx.ticket,
1047
+ role,
1048
+ composant,
1049
+ fichier,
1050
+ ],
1051
+ cwd: ctx.racine,
1052
+ };
1053
+ }
1054
+ return new Promise(resolve => {
1055
+ // The session writes at the ticket under the author of its role.
1056
+ const enfant = spawn(commande.cmd, commande.args, {
1057
+ cwd: commande.cwd,
1058
+ detached: true,
1059
+ stdio: fds,
1060
+ env: { ...process.env, [ACTEUR]: auteur(role, composant) },
1061
+ });
1062
+ for (const fd of new Set(fds.filter(f => typeof f === 'number'))) closeSync(fd);
1063
+ let erreurDeLancement = null;
1064
+ ecrireEtat(ctx, { ...ctx.courant, seance: enfant.pid ?? null });
1065
+ let bloque = false;
1066
+ let fini = false;
1067
+ const veille = surveiller(ctx, enfant.pid, () => (bloque = true));
1068
+ // A command that does not start (claude absent) ends with its error, whether or not its
1069
+ // `close` follows.
1070
+ enfant.on('error', e => {
1071
+ erreurDeLancement = e.message;
1072
+ if (!enfant.pid) enfant.emit('close', null);
1073
+ });
1074
+ enfant.on('close', code => {
1075
+ if (fini) return;
1076
+ fini = true;
1077
+ clearInterval(veille);
1078
+ // What remains of the group goes with its launcher.
1079
+ if (bloque) signalerGroupe(enfant.pid, 'SIGKILL');
1080
+ ecrireEtat(ctx, { ...ctx.courant, seance: null });
1081
+ let seance;
1082
+ let sortie;
1083
+ if (horsEnveloppe) {
1084
+ seance = erreurDeLancement === null ? code : null;
1085
+ sortie = lire(reponse).trim();
1086
+ appendFileSync(ctx.journal, `${sortie}\n`);
1087
+ spawnSync(process.execPath, [REGISTRE, '--copie', ctx.copie], {
1088
+ cwd: ctx.racine,
1089
+ stdio: ['ignore', 'ignore', 'ignore'],
1090
+ });
1091
+ } else {
1092
+ seance = existsSync(ctx.fin) ? Number(readFileSync(ctx.fin, 'utf8').trim()) : null;
1093
+ sortie = lire(ctx.resultat).trim();
1094
+ }
1095
+ resolve({ code, seance, bloque, erreur: erreurDeLancement ?? lire(erreurs), sortie });
1096
+ });
1097
+ });
1098
+ }
1099
+
1100
+ // <signal> to the process group <groupe>; false when the group is gone (or not the circuit's).
1101
+ function signalerGroupe(groupe, signal) {
1102
+ try {
1103
+ process.kill(-groupe, signal);
1104
+ return true;
1105
+ } catch {
1106
+ return false;
1107
+ }
1108
+ }
1109
+
1110
+ // The watch of the session whose launcher leads the group <groupe>: its transcripts and the log
1111
+ // standing still for the delay, SIGTERM, then SIGKILL, to the group, and <bloque> called. Its
1112
+ // interval, which the caller clears.
1113
+ function surveiller(ctx, groupe, bloque) {
1114
+ let bouge = Date.now();
1115
+ let arrete = false;
1116
+ const pas = Math.max(100, Math.min(30_000, Math.floor(ctx.immobileMs / 4)));
1117
+ return setInterval(() => {
1118
+ bouge = Math.max(bouge, derniereTrace(ctx.transcriptions), mtime(ctx.journal));
1119
+ if (!arrete && Date.now() - bouge >= ctx.immobileMs) {
1120
+ arrete = true;
1121
+ bloque();
1122
+ signalerGroupe(groupe, 'SIGTERM');
1123
+ setTimeout(() => signalerGroupe(groupe, 'SIGKILL'), 5_000).unref();
1124
+ }
1125
+ }, pas);
1126
+ }
1127
+
1128
+ // The wait for the session of group <groupe> that a dead circuit left running, under the same
1129
+ // watch as a session: a resumption starts once it has ended, so that two sessions never write
1130
+ // together in the copy.
1131
+ async function attendreLaSeance(ctx, groupe) {
1132
+ if (!groupe || !signalerGroupe(groupe, 0)) return;
1133
+ process.stderr.write(
1134
+ `… la séance du groupe ${groupe}, laissée par l'arrêt d'avant, tourne encore : le circuit attend sa fin.\n`
1135
+ );
1136
+ const veille = surveiller(ctx, groupe, () => {});
1137
+ while (signalerGroupe(groupe, 0)) await new Promise(r => setTimeout(r, 500));
1138
+ clearInterval(veille);
1139
+ }
1140
+
1141
+ // The template of the instruction at <rang> (0 the testwriter's and the developpeur's, 1 the
1142
+ // reviewer's, 2 the regulator's on a stop, 3 the regulator's on the supervisor's answer, 4 any
1143
+ // session's on a sheet absent or out of its schema, 5 the testwriter's and the developpeur's on a
1144
+ // resumption, 6 the regulator's on a decision the circuit refuses, 7 to 10 the questioning's, 11
1145
+ // the technical referent's on the supervisor's answer or the owner's decision), filled with
1146
+ // <valeurs>.
1147
+ function consigne(valeurs, rang = 0) {
1148
+ const texte = readFileSync(GABARIT, 'utf8');
1149
+ const bloc = [...texte.matchAll(/```text\n([\s\S]*?)\n```/g)][rang];
1150
+ if (!bloc) throw new Error(`gabarit sans bloc de consigne n° ${rang + 1} : ${GABARIT}`);
1151
+ return `${bloc[1].replace(/\{\{(\w+)\}\}/g, (m, k) => (k in valeurs ? valeurs[k] : m))}\n`;
1152
+ }
1153
+
1154
+ const nomDuRole = role => (role === 'developpeur' ? 'développeur' : role);
1155
+
1156
+ // One session of <role> on <composant>: its sheet. A relaunch on its step (<tentative> past 1)
1157
+ // that is not the last (<derniere>) resumes the agent's session the circuit keeps (`ctx.reprise`,
1158
+ // its author and id) with the short instruction (the sixth template); a resumption that fails (a
1159
+ // code other than 0, or no sheet that conforms) falls back on a fresh and whole session, under a
1160
+ // new id, which the circuit keeps. A fresh session's instruction (the first template) carries the
1161
+ // ticket's description and notes as they are at its launch, and the last handover of its author
1162
+ // at the ticket. Throws an Arret for a launch that stops before its session, a motionless
1163
+ // session, or a session that ends twice without a sheet that conforms.
1164
+ async function seance(ctx, role, composant, { tentative, rapport, fort, derniere }) {
1165
+ const agent = auteur(role, composant);
1166
+ const nom = nomDuRole(role);
1167
+ const reprise = tentative > 1 && !derniere && ctx.reprise?.agent === agent ? ctx.reprise : null;
1168
+ if (reprise) {
1169
+ const courte = {
1170
+ role: nom,
1171
+ ticket: ctx.ticket,
1172
+ sujet: ctx.sujet,
1173
+ composant,
1174
+ tentative,
1175
+ change:
1176
+ role === 'testwriter'
1177
+ ? 'la preuve du rouge, jouée après ta séance, refuse ton lot'
1178
+ : 'les contrôles, joués après ta séance, sont rouges',
1179
+ rapport: rapport ?? 'aucun',
1180
+ };
1181
+ const r = await lancer(ctx, role, composant, consigne(courte, 5), {
1182
+ fort,
1183
+ reprend: reprise.seance,
1184
+ });
1185
+ seanceLancee(ctx, role, r);
1186
+ const lu = r.seance === 0 ? lireFiche(role, r.sortie) : { erreur: `code ${r.seance}` };
1187
+ if (lu.fiche) return garder(ctx, role, composant, lu.fiche);
1188
+ commenter(
1189
+ ctx,
1190
+ composant,
1191
+ role,
1192
+ `reprise de la séance ${reprise.seance} sans fiche conforme (${lu.erreur}) : séance neuve et entière.`
1193
+ );
1194
+ }
1195
+ const lots = role === 'developpeur' ? testsDuLot(ctx) : testsDuComposant(ctx, composant);
1196
+ const { description, notes } = leTicket(ctx);
1197
+ // The last handover of this agent on its component; for a developpeur without one yet, the last
1198
+ // of the testwriter of his component.
1199
+ const passation =
1200
+ derniereDe(ctx, agent) ??
1201
+ (role === 'developpeur' ? derniereDe(ctx, auteur('testwriter', composant)) : null);
1202
+ const valeurs = {
1203
+ role: nom,
1204
+ depot: ctx.racine,
1205
+ ticket: ctx.ticket,
1206
+ sujet: ctx.sujet,
1207
+ composant,
1208
+ reprise:
1209
+ tentative > 1 || ctx.reprendre || ctx.depuis || ctx.renvois
1210
+ ? "oui : une séance d'avant a travaillé ce composant ; ta copie garde son travail, le rapport ci-dessous dit ce qui reste"
1211
+ : 'non',
1212
+ lots: lots.length
1213
+ ? `${role === 'developpeur' ? 'le lot de tests' : 'tes tests'}, déjà dans la copie : ${lots.join(', ')}`
1214
+ : 'aucun',
1215
+ contexte: contexteDe(ctx, composant),
1216
+ description: description.trim() || 'aucune',
1217
+ // The section of the ticket's notes, absent without notes.
1218
+ notes: notes.trim()
1219
+ ? `\nLES NOTES DU TICKET, l'état qu'un agent d'avant y a écrit :\n\n${notes.trim()}\n`
1220
+ : '',
1221
+ rapport: rapport ?? 'aucun',
1222
+ passation: passation?.text ?? 'aucune',
1223
+ };
1224
+ return await jusquALaFiche(ctx, role, composant, consigne(valeurs), { fort, neuve: agent });
1225
+ }
1226
+
1227
+ // The line CONTEXTE of a session on <composant>: the context of the relaunch --depuis that plays
1228
+ // it again (`renvois`, kept in the state), with the context of a run without --depuis, which every
1229
+ // agent receives; « aucun » without either.
1230
+ function contexteDe(ctx, composant) {
1231
+ const textes = [ctx.renvois?.[composant]?.contexte, !ctx.depuis && ctx.contexte];
1232
+ return [...new Set(textes.filter(Boolean))].join('\n\n') || 'aucun';
1233
+ }
1234
+
1235
+ // The launch <r> of a session of <role>, or the Arret it is: a motionless session (« bloqué »),
1236
+ // a launch that stops before its session (« lancement »).
1237
+ function seanceLancee(ctx, role, r) {
1238
+ if (r.bloque) {
1239
+ const minutes = Math.round(ctx.immobileMs / 60_000);
1240
+ throw new Arret(
1241
+ 'bloqué',
1242
+ `La transcription de la séance du ${role} n'a pas bougé en ${minutes ? `${minutes} min` : `${ctx.immobileMs} ms`} : la séance est arrêtée. Les dernières lignes du journal :\n\n${fin(lire(ctx.journal), 40)}`
1243
+ );
1244
+ }
1245
+ if (r.seance === null) {
1246
+ throw new Arret(
1247
+ 'lancement',
1248
+ `Le lanceur s'arrête avant la séance du ${role} (code ${r.code}).\n${r.erreur.trim()}\n\n${fin(lire(ctx.journal), 20)}`
1249
+ );
1250
+ }
1251
+ }
1252
+
1253
+ // The session of <role> on <composant> with the instruction <texte>, its sheet read in its result
1254
+ // (scripts/resultats.mjs): the sheet, kept with the ticket's sheets, its discoveries filed. A
1255
+ // session that ends without a sheet that conforms (none, one out of its schema, a code other than
1256
+ // 0) is resumed once, the same session, with the short instruction that names what lacks (the
1257
+ // fifth template). The session opens under the id <id> (a new one by default), or resumes it with
1258
+ // <premiere> « reprend » (the regulator on the supervisor's answer); with <neuve> (the author of a
1259
+ // testwriter or a developpeur), the circuit keeps that id for that author (`ctx.reprise`). Throws an
1260
+ // Arret for a launch that stops before its session, a motionless session, or twice no sheet
1261
+ // that conforms (« sans-fiche »). <avec> joins its fields to the sheet's entry (the lot a review
1262
+ // read).
1263
+ async function jusquALaFiche(ctx, role, composant, texte, options = {}) {
1264
+ const { neuve, premiere = 'ouvre', id = randomUUID(), avec = {}, ...reste } = options;
1265
+ if (neuve) ctx.reprise = { agent: neuve, seance: id };
1266
+ const ouvrir = mode =>
1267
+ reste.horsEnveloppe
1268
+ ? { session: [mode === 'ouvre' ? '--session-id' : '--resume', id] }
1269
+ : { [mode]: id };
1270
+ let lu = null;
1271
+ for (let essai = 1; essai <= 2; essai++) {
1272
+ const courte = () => consigne({ ticket: ctx.ticket, sujet: ctx.sujet, erreur: lu.erreur }, 4);
1273
+ const r = await lancer(ctx, role, composant, essai === 1 ? texte : courte(), {
1274
+ ...reste,
1275
+ ...ouvrir(essai === 1 ? premiere : 'reprend'),
1276
+ });
1277
+ seanceLancee(ctx, role, r);
1278
+ lu =
1279
+ r.seance === 0
1280
+ ? lireFiche(role, r.sortie)
1281
+ : { erreur: `la séance finit en erreur (code ${r.seance})` };
1282
+ if (lu.fiche) return garder(ctx, role, composant, lu.fiche, avec);
1283
+ if (essai === 1) {
1284
+ commenter(
1285
+ ctx,
1286
+ composant,
1287
+ role,
1288
+ `séance finie sans fiche conforme : ${lu.erreur} ; reprise une fois.`
1289
+ );
1290
+ }
1291
+ }
1292
+ throw new Arret(
1293
+ 'sans-fiche',
1294
+ `Deux fois, la séance du ${nomDuRole(role)} sur ${composant} finit sans fiche conforme : ${lu.erreur}. Le journal du ticket : ${ctx.journal}`
1295
+ );
1296
+ }
1297
+
1298
+ // The name of <composant> as a title writes it: its package's name, or its path.
1299
+ function nomDe(ctx, composant) {
1300
+ const liste = paquets(existsSync(ctx.copie) ? ctx.copie : ctx.racine);
1301
+ return liste.find(p => p.dossier === composant)?.pkg.name ?? composant;
1302
+ }
1303
+
1304
+ // The sheet <fiche> of <role> on <composant>, kept with the ticket's sheets, <avec> joined to its
1305
+ // entry, each of its discoveries filed closed under the ticket (scripts/ticket.sh, label
1306
+ // `decouverte`), its component the session's, or the ticket's for the reviewer and the regulator;
1307
+ // returns it.
1308
+ function garder(ctx, role, composant, fiche, avec = {}) {
1309
+ inscrire(ctx, { role, composant, fiche, ...avec });
1310
+ const composants = DESTINATAIRES.includes(role)
1311
+ ? nomDe(ctx, composant)
1312
+ : ctx.composants.map(c => nomDe(ctx, c)).join(', ');
1313
+ for (const d of fiche.decouvertes ?? []) {
1314
+ const r = spawnSync(
1315
+ 'bash',
1316
+ [TICKET_SH, ctx.ticket, d.sujet, composants, d.corps, 'decouverte'],
1317
+ {
1318
+ cwd: ctx.racine,
1319
+ encoding: 'utf8',
1320
+ env: { ...process.env, [ACTEUR]: AUTEUR_DU_CIRCUIT },
1321
+ }
1322
+ );
1323
+ if (r.status !== 0) {
1324
+ process.stderr.write(`⚠️ découverte « ${d.sujet} » non consignée : ${r.stderr}\n`);
1325
+ }
1326
+ }
1327
+ return fiche;
1328
+ }
1329
+
1330
+ const lire = f => (existsSync(f) ? readFileSync(f, 'utf8') : '');
1331
+
1332
+ // The circuit's reports since the reviewer's last sheet: those of its proofs and checks (the final
1333
+ // pass among them), as it wrote them at the ticket.
1334
+ function rapportsDuCircuit(ctx) {
1335
+ const tous = lesFiches(ctx);
1336
+ const depuis = tous.findLastIndex(e => e.role === 'relecteur') + 1;
1337
+ return tous
1338
+ .slice(depuis)
1339
+ .filter(e => e.role === AUTEUR_DU_CIRCUIT && e.rapport)
1340
+ .map(e => e.rapport);
1341
+ }
1342
+
1343
+ // The files of the lot, as the copy holds them against its HEAD: `{ tests, code }`, the
1344
+ // testwriter's files (`isTesterFile`: test files and the shared test material) and the others.
1345
+ function fichiersDuLot(ctx) {
1346
+ const tests = [];
1347
+ const code = [];
1348
+ for (const { fichier } of changements(ctx)) {
1349
+ (isTesterFile(fichier, ctx.copie) ? tests : code).push(fichier);
1350
+ }
1351
+ return { tests: tests.sort(), code: code.sort() };
1352
+ }
1353
+
1354
+ // The blocking findings of a reviewer's sheet <fiche>, the ones that send the lot back.
1355
+ const bloquants = fiche => (fiche.constats ?? []).filter(c => c.genre === 'bloquant');
1356
+
1357
+ // The blocking findings of a reviewer's sheet <fiche>, each under its heading, the criterion or
1358
+ // rule it holds, its recipient (or the criterion in question) and its body: lines.
1359
+ const lignesDesConstats = fiche =>
1360
+ bloquants(fiche).flatMap((c, i) => [
1361
+ `### Constat ${i + 1} — ${c.titre}`,
1362
+ `Bloquant : ${c.regle}`,
1363
+ c.critere_en_cause
1364
+ ? `Critère en cause : ${c.critere_en_cause}`
1365
+ : `Agent : ${c.role ?? '?'} ${c.composant ?? '?'}`,
1366
+ c.corps,
1367
+ '',
1368
+ ]);
1369
+
1370
+ // The verdict the reviewer's sheet <fiche> carries, as the circuit writes it at the ticket for the
1371
+ // human, its first word ACCEPTÉ or RENDU: after a bounded review, each finding before it lifted or
1372
+ // not, with its proof; each blocking finding under its heading, what it holds, its recipient (or
1373
+ // the criterion in question) and its body; then the remarks (its findings of `genre` remarque),
1374
+ // the question for the owner and the deviations to name at the commit.
1375
+ export function texteDuVerdict(fiche) {
1376
+ const section = (titre, l) => (l?.length ? [`## ${titre}`, '', ...l.map(x => `- ${x}`), ''] : []);
1377
+ const suivi = (fiche.suivi ?? []).map(
1378
+ x => `${x.titre} : ${x.etat}${x.preuve ? ` — ${x.preuve}` : ''}`
1379
+ );
1380
+ return [
1381
+ fiche.verdict,
1382
+ '',
1383
+ ...section("Constats d'avant", suivi),
1384
+ ...lignesDesConstats(fiche),
1385
+ ...section(
1386
+ 'Remarques',
1387
+ (fiche.constats ?? []).filter(c => c.genre === 'remarque').map(c => `${c.titre} — ${c.corps}`)
1388
+ ),
1389
+ ...section('Question au responsable', fiche.question ? [fiche.question] : []),
1390
+ ...section(
1391
+ 'Écarts à nommer au commit',
1392
+ fiche.ecarts?.map(e => `${e.sujet} : ${e.texte}`)
1393
+ ),
1394
+ ]
1395
+ .join('\n')
1396
+ .trim();
1397
+ }
1398
+
1399
+ // The review of the lot, the reviewer's session out of the envelope, from the second template:
1400
+ // `{ verdict, fiche, texte, arbre }`, its sheet, the verdict the circuit writes at the ticket from
1401
+ // it, under the reviewer's author, and the tree of the lot it read. The lot it reads is kept as a
1402
+ // patch set (`garderLeLot`), on the lot the review before read, joined to its sheet's entry (`lot:
1403
+ // { commit, arbre, fichiers }`); after a review sent back, the review reads the difference
1404
+ // between the two and the findings before it (the bounded review), as Gerrit has the next review
1405
+ // read the difference between two patch sets: the files of the lot that changed since, the
1406
+ // command of the difference, the findings with their recipients.
1407
+ async function relire(ctx, composant) {
1408
+ const { tests, code } = fichiersDuLot(ctx);
1409
+ const rapports = rapportsDuCircuit(ctx);
1410
+ const precedents = fichesDe(ctx, 'relecteur').length;
1411
+ const avant = relectureDAvant(ctx);
1412
+ const lot = garderLeLot(ctx, avant?.lot.commit);
1413
+ let lus = { tests, code };
1414
+ let precedente = 'aucune : tu relis le lot entier';
1415
+ if (avant && lot) {
1416
+ const dansLeLot = new Set([...tests, ...code, ...(avant.lot.fichiers ?? [])]);
1417
+ const changes = differenceDesLots(ctx, avant.lot.commit, lot.commit).filter(f =>
1418
+ dansLeLot.has(f)
1419
+ );
1420
+ lus = {
1421
+ tests: changes.filter(f => isTesterFile(f, ctx.copie)),
1422
+ code: changes.filter(f => !isTesterFile(f, ctx.copie)),
1423
+ };
1424
+ precedente = [
1425
+ `la relecture ${precedents}, ${avant.fiche.verdict} : tu relis la différence du lot depuis elle et ses constats, selon ta compétence (« La relecture bornée »).`,
1426
+ '',
1427
+ `- La différence : \`git diff ${avant.lot.commit} ${lot.commit}\`, dans la copie du ticket ; ses fichiers : ${changes.join(', ') || 'aucun'}.`,
1428
+ '- Ses constats bloquants, chacun avec son destinataire :',
1429
+ '',
1430
+ ...lignesDesConstats(avant.fiche),
1431
+ ]
1432
+ .join('\n')
1433
+ .trim();
1434
+ }
1435
+ const affirmations = path.join(ctx.tmp, 'affirmations.md');
1436
+ writeFileSync(affirmations, leTicket(ctx).description);
1437
+ const valeurs = {
1438
+ depot: ctx.racine,
1439
+ ticket: ctx.ticket,
1440
+ sujet: ctx.sujet,
1441
+ composants: ctx.composants.join(', '),
1442
+ reprise: precedents
1443
+ ? `oui : ${precedents} relecture(s) d'avant ont rendu le lot, et le lot a repassé le circuit depuis`
1444
+ : 'non',
1445
+ lots: [
1446
+ ...(avant && lot ? ["ce que le lot change depuis la relecture d'avant"] : []),
1447
+ `le lot de tests : ${lus.tests.join(', ') || 'aucun fichier'}`,
1448
+ `le lot de code : ${lus.code.join(', ') || 'aucun fichier'}`,
1449
+ ].join(' ; '),
1450
+ precedente,
1451
+ affirmations,
1452
+ rapports: rapports.join('\n\n') || 'aucun',
1453
+ // The regulator's context of a relaunch, the answer to a question of the reviewer among them.
1454
+ contexte: (!ctx.depuis && ctx.contexte) || 'aucun',
1455
+ };
1456
+ const fiche = await jusquALaFiche(ctx, 'relecteur', composant, consigne(valeurs, 1), {
1457
+ horsEnveloppe: true,
1458
+ droits: horsDeLEnveloppe(ctx, 'relecteur', 'opus'),
1459
+ avec: lot ? { lot: { ...lot, fichiers: [...tests, ...code] } } : {},
1460
+ });
1461
+ const texte = texteDuVerdict(fiche);
1462
+ const r = ctx.bdSous(AUTEUR_DU_VERDICT, 'comments', 'add', ctx.ticket, texte);
1463
+ if (r.status !== 0) process.stderr.write(`⚠️ verdict non écrit au ticket : ${r.stderr}\n`);
1464
+ return { verdict: fiche.verdict, fiche, texte, arbre: lot?.arbre ?? null };
1465
+ }
1466
+
1467
+ // The last review of the ticket and the lot it read: its sheet's entry (`{ fiche, lot: { commit,
1468
+ // arbre, fichiers } }`), or null before any, without a copy, or when the repository no longer
1469
+ // holds that lot.
1470
+ function relectureDAvant(ctx) {
1471
+ const e = lesFiches(ctx).findLast(x => x.role === 'relecteur');
1472
+ if (!e?.lot?.commit || !existsSync(ctx.copie)) return null;
1473
+ const r = spawnSync('git', ['-C', ctx.copie, 'cat-file', '-e', `${e.lot.commit}^{commit}`]);
1474
+ return r.status === 0 ? e : null;
1475
+ }
1476
+
1477
+ // The tree of the copy as it stands, the copy's HEAD and index untouched: its index copied to a
1478
+ // file of the circuit's, every file of the copy added to that one (`git add -A`, the ignored files
1479
+ // left out), written as a tree. Its id, or null when git fails.
1480
+ function arbreDeLaCopie(ctx) {
1481
+ const index = path.join(ctx.tmp, `index-${++ctx.numero}`);
1482
+ const reel = spawnSync(
1483
+ 'git',
1484
+ ['-C', ctx.copie, 'rev-parse', '--path-format=absolute', '--git-path', 'index'],
1485
+ { encoding: 'utf8' }
1486
+ ).stdout?.trim();
1487
+ const env = { ...process.env, GIT_INDEX_FILE: index };
1488
+ const git = (...a) => spawnSync('git', ['-C', ctx.copie, ...a], { encoding: 'utf8', env });
1489
+ try {
1490
+ if (reel && existsSync(reel)) copyFileSync(reel, index);
1491
+ else if (git('read-tree', 'HEAD').status !== 0) return null;
1492
+ if (git('add', '-A').status !== 0) return null;
1493
+ const r = git('write-tree');
1494
+ return r.status === 0 ? r.stdout.trim() : null;
1495
+ } finally {
1496
+ rmSync(index, { force: true });
1497
+ }
1498
+ }
1499
+
1500
+ // The lot of the copy kept as a commit on <parent> (the lot kept before, or HEAD), as Gerrit keeps
1501
+ // each patch set of a change: `{ commit, arbre }`, or null when git fails. Turborepo compares two
1502
+ // of them (`--filter=...[<avant>...<après>]`, the merge base of the range being the first), and
1503
+ // the reviewer reads their difference.
1504
+ function garderLeLot(ctx, parent) {
1505
+ const arbre = arbreDeLaCopie(ctx);
1506
+ if (!arbre) return null;
1507
+ const qui = { name: AUTEUR_DU_CIRCUIT, email: 'circuit@grillhouse.invalid' };
1508
+ const r = spawnSync(
1509
+ 'git',
1510
+ ['-C', ctx.copie, 'commit-tree', arbre, '-p', parent ?? 'HEAD', '-m', `lot de ${ctx.ticket}`],
1511
+ {
1512
+ encoding: 'utf8',
1513
+ env: {
1514
+ ...process.env,
1515
+ GIT_AUTHOR_NAME: qui.name,
1516
+ GIT_AUTHOR_EMAIL: qui.email,
1517
+ GIT_COMMITTER_NAME: qui.name,
1518
+ GIT_COMMITTER_EMAIL: qui.email,
1519
+ },
1520
+ }
1521
+ );
1522
+ return r.status === 0 ? { commit: r.stdout.trim(), arbre } : null;
1523
+ }
1524
+
1525
+ // The files that differ between the lots kept <avant> and <apres>, relative to the copy, sorted.
1526
+ function differenceDesLots(ctx, avant, apres) {
1527
+ const r = spawnSync('git', ['-C', ctx.copie, 'diff', '--name-only', '-z', avant, apres], {
1528
+ encoding: 'utf8',
1529
+ maxBuffer: 64 * 1024 * 1024,
1530
+ });
1531
+ return r.status === 0 ? r.stdout.split('\0').filter(Boolean).sort() : [];
1532
+ }
1533
+
1534
+ // The final pass the lot calls for: `{ arbre, restreinte }`, the tree of the copy, and, after a
1535
+ // review whose lot it no longer is the accepted one, the range Turborepo plays (`{ avant, apres }`:
1536
+ // the lot that review read and the copy kept on it), null for the whole pass: before any review,
1537
+ // or on the lot the last review accepted.
1538
+ function laPasseFinale(ctx) {
1539
+ const avant = relectureDAvant(ctx);
1540
+ const arbre = arbreDeLaCopie(ctx);
1541
+ if (!avant || !arbre) return { arbre, restreinte: null };
1542
+ if (avant.fiche.verdict === 'ACCEPTÉ' && avant.lot.arbre === arbre) {
1543
+ return { arbre, restreinte: null };
1544
+ }
1545
+ const apres = garderLeLot(ctx, avant.lot.commit);
1546
+ return {
1547
+ arbre,
1548
+ restreinte: apres ? { avant: avant.lot.commit, apres: apres.commit } : null,
1549
+ };
1550
+ }
1551
+
1552
+ // The references the project declares in the package.json of the copy (`grillhouse.references`):
1553
+ // each `{ nom, commande, fichiers, tests, gardes }` with a name, a command and files, `tests` and
1554
+ // `gardes` arrays when given, empty when not; none without them.
1555
+ function lesReferences(ctx) {
1556
+ const l = lireJson(path.join(ctx.copie, 'package.json'))?.grillhouse?.references;
1557
+ return (Array.isArray(l) ? l : [])
1558
+ .filter(
1559
+ r =>
1560
+ typeof r?.nom === 'string' &&
1561
+ typeof r.commande === 'string' &&
1562
+ Array.isArray(r.fichiers) &&
1563
+ r.fichiers.length > 0 &&
1564
+ (r.tests === undefined || Array.isArray(r.tests)) &&
1565
+ (r.gardes === undefined || Array.isArray(r.gardes))
1566
+ )
1567
+ .map(r => ({ ...r, tests: r.tests ?? [], gardes: r.gardes ?? [] }));
1568
+ }
1569
+
1570
+ // Whether one of <motifs> covers the file <fichier>, both from the root of the copy, as the
1571
+ // inputs of Turborepo read them: a path names that file, a directory every file under it, a glob
1572
+ // pattern (`*`, `**`) the files it matches (`path.matchesGlob` of Node).
1573
+ export function couvertPar(motifs, fichier) {
1574
+ return motifs.some(m => {
1575
+ const dossier = m.replace(/\/+$/, '');
1576
+ return fichier === dossier || fichier.startsWith(`${dossier}/`) || path.matchesGlob(fichier, m);
1577
+ });
1578
+ }
1579
+
1580
+ // The references the project declares, when every red of a final pass, by its place of <lieux>
1581
+ // (`{ fichier, gardes }`, the names its finding carries), is one of theirs: a finding of one of
1582
+ // their `gardes`, a file of their `tests`, or a file their `fichiers` cover; none otherwise.
1583
+ function referencesDesRouges(ctx, lieux) {
1584
+ const refs = lesReferences(ctx);
1585
+ const leur = ({ fichier: f, gardes }) =>
1586
+ refs.some(
1587
+ r =>
1588
+ gardes.some(g => r.gardes.includes(g)) ||
1589
+ (f && (r.tests.includes(f) || couvertPar(r.fichiers, f)))
1590
+ );
1591
+ return refs.length && lieux.length && lieux.every(leur) ? refs : [];
1592
+ }
1593
+
1594
+ // The regeneration of the references the project declares (3.1): the copy kept as a lot on the
1595
+ // one the last review read (or HEAD), each command run on the whole copy, the copy kept again on
1596
+ // that lot. `{ texte, regenerees, fichiers, restreinte }`: the report, the names of the
1597
+ // references changed, the files changed, and the range of the final pass that follows. A
1598
+ // reference changed that the ticket's description does not name (its name or one of its files),
1599
+ // a file changed that no reference declares, a command at another code than 0 or a regeneration
1600
+ // that changes nothing restores the copy as it was and stops at « references ».
1601
+ function regenerer(ctx, composant) {
1602
+ const refs = lesReferences(ctx);
1603
+ const avant = garderLeLot(ctx, relectureDAvant(ctx)?.lot.commit);
1604
+ if (!avant) {
1605
+ throw new Arret(
1606
+ 'references',
1607
+ 'Le circuit ne garde pas la copie avant la régénération des références (git échoue) : la décision revient au régulateur.'
1608
+ );
1609
+ }
1610
+ const echecs = refs
1611
+ .map(r => ({ r, code: commandeDeReference(ctx, composant, r.commande) }))
1612
+ .filter(x => x.code !== 0)
1613
+ .map(x => `- ${x.r.nom} : \`${x.r.commande}\` rend le code ${x.code}.`);
1614
+ const apres = garderLeLot(ctx, avant.commit);
1615
+ const fichiers = apres ? differenceDesLots(ctx, avant.commit, apres.commit) : [];
1616
+ const description = leTicket(ctx).description;
1617
+ const siens = r => fichiers.filter(f => couvertPar(r.fichiers, f));
1618
+ const regenerees = refs.filter(r => siens(r).length);
1619
+ const nommee = r => [r.nom, ...r.fichiers].some(x => description.includes(x));
1620
+ const horsDeclaration = fichiers.filter(f => !refs.some(r => couvertPar(r.fichiers, f)));
1621
+ const ecarts = [
1622
+ ...echecs,
1623
+ ...(!apres ? ['- Le circuit ne garde pas la copie après la régénération (git échoue).'] : []),
1624
+ ...(apres && !fichiers.length
1625
+ ? [
1626
+ `- La régénération (${refs.map(r => `\`${r.commande}\``).join(', ')}) ne change aucun fichier : les rouges de la passe finale restent.`,
1627
+ ]
1628
+ : []),
1629
+ ...regenerees
1630
+ .filter(r => !nommee(r))
1631
+ .map(
1632
+ r =>
1633
+ `- ${r.nom} change (${siens(r).join(', ')}), et le plan du ticket ne nomme pas ce changement (ni « ${r.nom} » ni ses fichiers).`
1634
+ ),
1635
+ ...horsDeclaration.map(f => `- ${f} change, et aucune référence déclarée ne l'écrit.`),
1636
+ ];
1637
+ if (ecarts.length) {
1638
+ restaurer(ctx, avant.commit, fichiers);
1639
+ throw new Arret(
1640
+ 'references',
1641
+ [
1642
+ `La régénération des références, sur la copie entière, s'arrête ; la copie est rendue telle qu'avant elle. La décision revient au régulateur : faire nommer le changement au plan du ticket par le superviseur (\`MONTE\`), puis \`RELANCE --reprendre\` ; ou faire corriger le lot qui le cause (\`RELANCE --depuis developpeur <composant>\`).`,
1643
+ '',
1644
+ ...ecarts,
1645
+ '',
1646
+ ...(apres && fichiers.length
1647
+ ? [
1648
+ `La différence, avant d'être rendue : \`git diff ${avant.commit} ${apres.commit}\`, dans la copie du ticket.`,
1649
+ ]
1650
+ : []),
1651
+ `Le journal du ticket : ${ctx.journal}`,
1652
+ ].join('\n')
1653
+ );
1654
+ }
1655
+ return {
1656
+ texte: [
1657
+ `Les références régénérées sur la copie entière : ${regenerees.map(r => r.nom).join(', ')} ; le lot porte leurs fichiers, puis la passe finale restreinte à ce qu'elles changent.`,
1658
+ '',
1659
+ ...regenerees.map(r => `- ${r.nom} (\`${r.commande}\`) : ${siens(r).join(', ')}`),
1660
+ '',
1661
+ `La différence : \`git diff ${avant.commit} ${apres.commit}\`, dans la copie du ticket.`,
1662
+ ].join('\n'),
1663
+ regenerees: regenerees.map(r => r.nom),
1664
+ fichiers,
1665
+ restreinte: { avant: avant.commit, apres: apres.commit },
1666
+ };
1667
+ }
1668
+
1669
+ // One command of a reference, run by the shell at the root of the copy, out of the envelope,
1670
+ // under the lock of the machine, its output added to the log: its exit code.
1671
+ function commandeDeReference(ctx, composant, commande) {
1672
+ entete(ctx, 'references', composant);
1673
+ appendFileSync(ctx.journal, `$ ${commande}\n`);
1674
+ const fd = openSync(ctx.journal, 'a');
1675
+ try {
1676
+ const r = avecLaMachine(
1677
+ env => spawnSync('sh', ['-c', commande], { cwd: ctx.copie, stdio: ['ignore', fd, fd], env }),
1678
+ { dire: l => appendFileSync(ctx.journal, `${l}\n`) }
1679
+ );
1680
+ return r.status ?? 1;
1681
+ } catch (e) {
1682
+ if (!(e instanceof AttenteDeLaMachine)) throw e;
1683
+ appendFileSync(ctx.journal, `${e.message}\n`);
1684
+ return e.code;
1685
+ } finally {
1686
+ closeSync(fd);
1687
+ }
1688
+ }
1689
+
1690
+ // The files <fichiers> of the copy as the lot kept <commit> holds them, a file it does not hold
1691
+ // removed; the copy's index untouched.
1692
+ function restaurer(ctx, commit, fichiers) {
1693
+ for (const f of fichiers) {
1694
+ const tient = spawnSync('git', ['-C', ctx.copie, 'cat-file', '-e', `${commit}:${f}`]);
1695
+ if (tient.status === 0) {
1696
+ spawnSync('git', ['-C', ctx.copie, 'restore', `--source=${commit}`, '--worktree', '--', f]);
1697
+ } else {
1698
+ rmSync(path.join(ctx.copie, f), { force: true });
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ // The stop for the sheet <fiche> of the session <tentative> of <role> on <composant>, BLOQUÉ
1704
+ // (« hors-portee ») or BESOIN (« besoin »): its reason or question first, then its handover.
1705
+ function arretDuStatut(ctx, role, composant, tentative, fiche) {
1706
+ const passation = derniereDe(ctx, auteur(role, composant))?.text ?? 'aucune';
1707
+ const qui = `Le ${nomDuRole(role)} de ${composant}`;
1708
+ return fiche.statut === 'BESOIN'
1709
+ ? new Arret(
1710
+ 'besoin',
1711
+ `${qui} attend une réponse (séance ${tentative}) : ${fiche.question}\n\nLa décision revient au régulateur. Sa passation :\n\n${passation}`
1712
+ )
1713
+ : new Arret(
1714
+ 'hors-portee',
1715
+ `${qui} se déclare bloqué (séance ${tentative}) : ${fiche.raison}\n\nLe circuit ne le relance pas sur ce rouge : la décision revient au régulateur. Sa passation :\n\n${passation}`
1716
+ );
1717
+ }
1718
+
1719
+ // The index in the title of the component <nom> (a path or a package name), or -1.
1720
+ function indexDe(ctx, nom) {
1721
+ try {
1722
+ return ctx.composants.indexOf(ctx.resoudre(nom));
1723
+ } catch {
1724
+ return -1;
1725
+ }
1726
+ }
1727
+
1728
+ // The sending to a testwriter in the sheet <fiche> of the developpeur of the component at <index>:
1729
+ // `{ index, texte }`, the component its `renvoi` names, one of the title up to <index>, and what
1730
+ // that testwriter receives (the sending, then the developpeur's handover); null without a sending,
1731
+ // or for a component out of the title or after his own.
1732
+ function renvoiAuTestwriter(ctx, fiche, index) {
1733
+ if (!fiche.renvoi) return null;
1734
+ const i = indexDe(ctx, fiche.renvoi.composant);
1735
+ if (i < 0 || i > index) return null;
1736
+ const composant = ctx.composants[index];
1737
+ const passation = derniereDe(ctx, auteur('developpeur', composant))?.text ?? 'aucune';
1738
+ const raison = fiche.raison ? ` : ${fiche.raison}` : '';
1739
+ return {
1740
+ index: i,
1741
+ texte: `Le développeur de ${composant} renvoie le rouge qui reste aux tests de ${ctx.composants[i]}${raison}.\n\nSa passation :\n\n${passation}`,
1742
+ };
1743
+ }
1744
+
1745
+ // The agents the reviewer's sheet RENDU <fiche> sends the lot back to, by its blocking findings
1746
+ // alone (a remark reaches the ticket, no agent; the sheet's schema gives a RENDU at least one):
1747
+ // Map index of component → `{ etape, testwriter, developpeur }`, `etape` the first step that
1748
+ // component plays again (testwriter when a finding names its testwriter), `testwriter` and
1749
+ // `developpeur` the findings for each, or null. Throws an Arret `relecture-arret` for a finding
1750
+ // without a valid recipient (a component the title does not name) or one that puts a criterion in
1751
+ // question.
1752
+ function renvoisDuVerdict(ctx, fiche, texte) {
1753
+ const liste = bloquants(fiche);
1754
+ const arret = raison =>
1755
+ new Arret(
1756
+ 'relecture-arret',
1757
+ `${raison} La décision revient au régulateur.\n\nLe verdict :\n\n${texte}`
1758
+ );
1759
+ const renvois = new Map();
1760
+ for (const [n, c] of liste.entries()) {
1761
+ if (c.critere_en_cause)
1762
+ throw arret(`Le constat « ${c.titre} » met en cause un critère du ticket.`);
1763
+ const index = c.composant ? indexDe(ctx, c.composant) : -1;
1764
+ if (!DESTINATAIRES.includes(c.role) || index < 0) {
1765
+ throw arret(
1766
+ `Le constat « ${c.titre} » n'a pas de destinataire valide : il nomme ${c.role ? `« ${c.role} ${c.composant ?? ''} »` : 'personne'}, quand le rôle est testwriter ou developpeur et le composant l'un du titre (${ctx.composants.join(', ')}).`
1767
+ );
1768
+ }
1769
+ const r = renvois.get(index) ?? { etape: 'developpeur', testwriter: [], developpeur: [] };
1770
+ r[c.role].push(`### Constat ${n + 1} — ${c.titre}\n${c.corps}`);
1771
+ if (c.role === 'testwriter') r.etape = 'testwriter';
1772
+ renvois.set(index, r);
1773
+ }
1774
+ const joindre = l => (l.length ? l.join('\n\n') : null);
1775
+ return new Map(
1776
+ [...renvois].map(([i, r]) => [
1777
+ i,
1778
+ { etape: r.etape, testwriter: joindre(r.testwriter), developpeur: joindre(r.developpeur) },
1779
+ ])
1780
+ );
1781
+ }
1782
+
1783
+ // The integration of the lot: its two patches and its message, written from the copy, then
1784
+ // scripts/integration/integrer.mjs from the main tree, its output added to the log. A run « à
1785
+ // repasser » (75) plays once more. `{ code, sortie }`.
1786
+ function integrer(ctx) {
1787
+ const { tests, code } = fichiersDuLot(ctx);
1788
+ const dansCopie = (...a) =>
1789
+ spawnSync('git', ['-C', ctx.copie, ...a], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
1790
+ // Each file against HEAD, a new one against nothing: the index of the copy is not written.
1791
+ const patch = fichiers =>
1792
+ fichiers
1793
+ .map(f =>
1794
+ dansCopie('cat-file', '-e', `HEAD:${f}`).status === 0
1795
+ ? dansCopie('diff', '--binary', 'HEAD', '--', f).stdout
1796
+ : dansCopie('diff', '--no-index', '--binary', '/dev/null', f).stdout
1797
+ )
1798
+ .join('');
1799
+ const args = ['--ticket', ctx.ticket];
1800
+ for (const [option, fichiers] of [
1801
+ ['--tests', tests],
1802
+ ['--code', code],
1803
+ ]) {
1804
+ if (!fichiers.length) continue;
1805
+ const f = path.join(ctx.tmp, `${option.slice(2)}.patch`);
1806
+ writeFileSync(f, patch(fichiers));
1807
+ args.push(option, f);
1808
+ }
1809
+ const message = path.join(ctx.tmp, 'message');
1810
+ writeFileSync(message, messageDeCommit(ctx));
1811
+ args.push('--message', message);
1812
+ let r;
1813
+ for (let essai = 1; essai <= 2; essai++) {
1814
+ entete(ctx, 'integration', ctx.composants.join(', '));
1815
+ appendFileSync(ctx.journal, `$ node ${INTEGRER} ${args.join(' ')}\n`);
1816
+ const x = avecAvancement(ctx, 'integration', avancement =>
1817
+ spawnSync(process.execPath, [INTEGRER, ...args], {
1818
+ cwd: ctx.racine,
1819
+ encoding: 'utf8',
1820
+ maxBuffer: 256 * 1024 * 1024,
1821
+ env: { ...process.env, [AVANCEMENT]: avancement },
1822
+ })
1823
+ );
1824
+ r = { code: x.status ?? 1, sortie: `${x.stdout ?? ''}${x.stderr ?? ''}` };
1825
+ appendFileSync(ctx.journal, r.sortie);
1826
+ if (r.code !== A_REPASSER) break;
1827
+ }
1828
+ return r;
1829
+ }
1830
+
1831
+ // The queue (scripts/integration/file.mjs), launched from the main tree and waited for, its output
1832
+ // added to the log; then the state it leaves to the ticket: `{ etat, raison }`, `etat` ferme (the
1833
+ // ticket closed, its branch on main and its CI read), en-file (the branch still in the queue, the
1834
+ // queue's reason) or rouvert (out of the queue, or a red CI, the queue's last comment). A queue
1835
+ // already running exits 0 at once: it takes the branch, and the state is read at its end too.
1836
+ function file(ctx) {
1837
+ const motsDeLaFile = () => passations(ctx).filter(c => c.author === AUTEUR_DE_LA_FILE);
1838
+ const avant = motsDeLaFile().length;
1839
+ entete(ctx, 'file', ctx.composants.join(', '));
1840
+ appendFileSync(ctx.journal, `$ node ${FILE}\n`);
1841
+ const x = avecAvancement(ctx, null, avancement =>
1842
+ spawnSync(process.execPath, [FILE], {
1843
+ cwd: ctx.racine,
1844
+ encoding: 'utf8',
1845
+ maxBuffer: 256 * 1024 * 1024,
1846
+ env: { ...process.env, [AVANCEMENT]: avancement },
1847
+ })
1848
+ );
1849
+ appendFileSync(ctx.journal, `${x.stdout ?? ''}${x.stderr ?? ''}`);
1850
+ const branche = `agent/${ctx.ticket}`;
1851
+ // Another queue that runs takes the branch: the circuit waits for its end.
1852
+ while (lireEntrees(ctx.racine).some(e => e.branche === branche) && fileEnCours(ctx)) {
1853
+ attendreUnPeu(5_000);
1854
+ }
1855
+ const show = ctx.bd('show', ctx.ticket, '--json');
1856
+ const t = show.status === 0 ? (JSON.parse(show.stdout)[0] ?? {}) : {};
1857
+ // The queue's last word at the ticket, under its author, since this queue began.
1858
+ const mot = motsDeLaFile().slice(avant).at(-1)?.text ?? null;
1859
+ const raison = mot ?? 'aucun commentaire de la file';
1860
+ if (lireEntrees(ctx.racine).some(e => e.branche === branche)) return { etat: 'en-file', raison };
1861
+ if (t.status === 'closed') {
1862
+ // A CI the queue could not read is its last word at a ticket it closes.
1863
+ const motif = t.close_reason || 'ticket fermé';
1864
+ return { etat: 'ferme', raison: mot ? `${motif} ; ${enUneLigne(mot)}` : motif };
1865
+ }
1866
+ return { etat: 'rouvert', raison };
1867
+ }
1868
+
1869
+ // True while a queue holds its lock (`.git/file-integration.lock`), its process alive.
1870
+ function fileEnCours(ctx) {
1871
+ const verrou = path.join(dossierCommun(ctx.racine), 'file-integration.lock');
1872
+ const pid = Number.parseInt(lire(verrou), 10);
1873
+ return Number.isInteger(pid) && vivant(pid);
1874
+ }
1875
+
1876
+ // The type of a conventional commit for the ticket's type in Beads; `feat` for any other.
1877
+ const TYPES_DE_COMMIT = { bug: 'fix', chore: 'chore', docs: 'docs' };
1878
+
1879
+ // The commit message of the lot: `<type>(<composants>): <sujet> (<ticket>)`, the components by
1880
+ // their package name; then the message the sheets of the developpeurs and of the reviewer give,
1881
+ // in their order, written once from the final state (`messageDuLot`).
1882
+ function messageDeCommit(ctx) {
1883
+ const liste = paquets(existsSync(ctx.copie) ? ctx.copie : ctx.racine);
1884
+ const noms = ctx.composants
1885
+ .filter(c => c !== '.')
1886
+ .map(c => liste.find(p => p.dossier === c)?.pkg.name ?? path.basename(c));
1887
+ const type = TYPES_DE_COMMIT[ctx.type] ?? 'feat';
1888
+ const portee = noms.length ? `(${noms.join(',')})` : '';
1889
+ return messageDuLot({
1890
+ entete: `${type}${portee}: ${ctx.sujet} (${ctx.ticket})`,
1891
+ description: leTicket(ctx).description,
1892
+ fiches: lesFiches(ctx)
1893
+ .filter(e => e.role === 'developpeur' || e.role === 'relecteur')
1894
+ .map(e => e.fiche),
1895
+ });
1896
+ }
1897
+
1898
+ // The commit message of a lot, written once from its final state, as a change merged on Gerrit
1899
+ // carries the one description of its last patch set: <entete>, the title of the ticket; the
1900
+ // section of the criteria of the plan <description> (its heading begins with « Critères »), what
1901
+ // the lot changes; a paragraph per deviation the sheets <fiches> carry (`ecarts`), each subject
1902
+ // once, in the order it first appears, with the value of the last sheet that carries it (a
1903
+ // subject known again whatever its case and spacing); then the trailer.
1904
+ export function messageDuLot({ entete, description, fiches }) {
1905
+ const criteres = [];
1906
+ let dedans = false;
1907
+ for (const ligne of String(description ?? '').split('\n')) {
1908
+ const m = /^##\s+(.*)$/.exec(ligne);
1909
+ if (m) {
1910
+ dedans = /^Critères\b/.test(m[1].trim());
1911
+ } else if (dedans) {
1912
+ criteres.push(ligne);
1913
+ }
1914
+ }
1915
+ const cle = sujet => sujet.toLocaleLowerCase('fr').replace(/\s+/g, ' ').trim();
1916
+ const ecarts = new Map();
1917
+ for (const e of fiches.flatMap(f => f.ecarts ?? [])) {
1918
+ const sujet = ecarts.get(cle(e.sujet))?.sujet ?? e.sujet.trim();
1919
+ ecarts.set(cle(e.sujet), { sujet, texte: e.texte.trim() });
1920
+ }
1921
+ return [
1922
+ entete,
1923
+ criteres.join('\n').trim(),
1924
+ ...[...ecarts.values()].map(e => `${e.sujet} : ${e.texte}`),
1925
+ 'Co-Authored-By: Claude <noreply@anthropic.com>',
1926
+ ]
1927
+ .filter(Boolean)
1928
+ .join('\n\n')
1929
+ .concat('\n');
1930
+ }
1931
+
1932
+ // The files the copy changes against its HEAD: [{ fichier, supprime }], relative to the copy.
1933
+ function changements(ctx) {
1934
+ const r = spawnSync(
1935
+ 'git',
1936
+ ['-C', ctx.copie, 'status', '--porcelain', '-z', '--untracked-files=all'],
1937
+ { encoding: 'utf8' }
1938
+ );
1939
+ if (r.status !== 0) return [];
1940
+ const entrees = r.stdout.split('\0');
1941
+ const out = [];
1942
+ for (let i = 0; i < entrees.length; i++) {
1943
+ const e = entrees[i];
1944
+ if (e.length < 4) continue;
1945
+ const xy = e.slice(0, 2);
1946
+ out.push({ fichier: e.slice(3), supprime: xy.includes('D') });
1947
+ // A rename or a copy names its source next.
1948
+ if (xy.includes('R') || xy.includes('C')) i++;
1949
+ }
1950
+ return out;
1951
+ }
1952
+
1953
+ // The test files of the lot that the testwriter writes, present in the copy.
1954
+ function testsDuLot(ctx) {
1955
+ return changements(ctx)
1956
+ .filter(c => !c.supprime)
1957
+ .map(c => c.fichier)
1958
+ .filter(f => NOM_DE_TEST.test(path.basename(f)) && isTesterFile(f, ctx.copie))
1959
+ .sort();
1960
+ }
1961
+
1962
+ // The test files of the lot written in <composant> or in the shared test material: the testwriter's
1963
+ // lot of this component.
1964
+ function testsDuComposant(ctx, composant) {
1965
+ const dossiers = [
1966
+ path.join(ctx.copie, composant),
1967
+ ...(existsSync(ctx.copie) ? matieres(ctx.copie).map(m => m.dir) : []),
1968
+ ];
1969
+ return testsDuLot(ctx).filter(f => {
1970
+ const abs = path.join(ctx.copie, f);
1971
+ return dossiers.some(d => {
1972
+ const rel = path.relative(d, abs);
1973
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
1974
+ });
1975
+ });
1976
+ }
1977
+
1978
+ // The package directory of the copy that holds <fichier>, or « . ».
1979
+ function paquetDe(liste, fichier) {
1980
+ const p = liste.find(({ dossier }) => `${fichier}`.startsWith(`${dossier}${path.sep}`));
1981
+ return p ? p.dossier : '.';
1982
+ }
1983
+
1984
+ // One npm command in the copy, under <env> added to the circuit's environment, its results written
1985
+ // to a directory of its own (RESULTATS, scripts/resultats.mjs: the summaries of Turborepo, the
1986
+ // JSON reports of Vitest, the SARIF of the guards), its output added to the log for the human:
1987
+ // `{ code, dossier }`. The circuit reads its results, never its output. It is a heavy pass (the
1988
+ // proof, the checks, the final pass): it holds the lock of the machine
1989
+ // (scripts/verrou-de-machine.mjs), its wait written to the log, and passes it to the
1990
+ // `npm run verifier` it runs. A wait that stops (its bound, 75; a lock held by an ancestor, 1) is
1991
+ // the command's code, its message in the log. Once it holds the lock, the pass plays under a
1992
+ // progress directory of its own (`avancement`), of the kind `genreDeLaPasse` names.
1993
+ function npm(ctx, etape, composant, args, env = {}) {
1994
+ entete(ctx, etape, composant);
1995
+ const dossier = mkdtempSync(path.join(ctx.base.dossier, 'resultats-'));
1996
+ const prefixe = Object.entries(env).map(([k, v]) => `${k}=${v} `);
1997
+ appendFileSync(ctx.journal, `$ ${prefixe.join('')}npm ${args.join(' ')}\n`);
1998
+ const fd = openSync(ctx.journal, 'a');
1999
+ try {
2000
+ const r = avecLaMachine(
2001
+ envMachine =>
2002
+ avecAvancement(ctx, genreDeLaPasse(etape, composant, args), avancement =>
2003
+ spawnSync('npm', args, {
2004
+ cwd: ctx.copie,
2005
+ stdio: ['ignore', fd, fd],
2006
+ env: {
2007
+ ...envMachine,
2008
+ ...env,
2009
+ [RESULTATS]: dossier,
2010
+ [AVANCEMENT]: avancement,
2011
+ ...(args[0] === 'test' ? { [DEPUIS_LE_VERT]: '1' } : {}),
2012
+ },
2013
+ })
2014
+ ),
2015
+ { dire: l => appendFileSync(ctx.journal, `${l}\n`) }
2016
+ );
2017
+ return { code: r.status ?? 1, dossier };
2018
+ } catch (e) {
2019
+ if (!(e instanceof AttenteDeLaMachine)) throw e;
2020
+ appendFileSync(ctx.journal, `${e.message}\n`);
2021
+ return { code: e.code, dossier };
2022
+ } finally {
2023
+ closeSync(fd);
2024
+ }
2025
+ }
2026
+
2027
+ // The kind of an automatic pass, by which its total is kept from one pass to the next
2028
+ // (scripts/avancement.mjs): its step (the checks once final, the final pass), its component, and
2029
+ // its command without the test files and the filters, which move from one lot to the next.
2030
+ function genreDeLaPasse(etape, composant, args) {
2031
+ const commande = args.filter(
2032
+ a => !NOM_DE_TEST.test(path.basename(a)) && !a.startsWith('--filter=')
2033
+ );
2034
+ return etape === 'controles' && args[1] === 'verifier'
2035
+ ? ['passe finale', ...commande].join(' ')
2036
+ : [etape, composant, ...commande].join(' ');
2037
+ }
2038
+
2039
+ // Runs <passe> under a progress directory of the kind <genre> (null: none kept), named in the
2040
+ // state while it plays (`avancement`), as the board reads it; then keeps its total, takes it out
2041
+ // of the state and removes it. Returns what <passe> returns.
2042
+ function avecAvancement(ctx, genre, passe) {
2043
+ const avancement = ouvrirAvancement({ racine: ctx.copie, genre, dans: ctx.base.dossier });
2044
+ if (ctx.courant) ecrireEtat(ctx, { ...ctx.courant, avancement: { dossier: avancement } });
2045
+ try {
2046
+ return passe(avancement);
2047
+ } finally {
2048
+ garderLeTotal(avancement, ctx.copie);
2049
+ if (ctx.courant) ecrireEtat(ctx, { ...ctx.courant, avancement: null });
2050
+ rmSync(avancement, { recursive: true, force: true });
2051
+ }
2052
+ }
2053
+
2054
+ // HEAD's version of each file of <fichiers> that HEAD holds, written beside it in the copy under
2055
+ // the name `<nom>.circuit-avant.<test|spec>.<ext>`, which the same run plays: Map file → sibling,
2056
+ // both relative to the copy. The caller removes the siblings.
2057
+ function versionsDAvant(ctx, fichiers) {
2058
+ const out = new Map();
2059
+ for (const f of fichiers) {
2060
+ const r = spawnSync('git', ['-C', ctx.copie, 'show', `HEAD:${f}`], {
2061
+ encoding: 'utf8',
2062
+ maxBuffer: 64 * 1024 * 1024,
2063
+ });
2064
+ if (r.status !== 0) continue;
2065
+ const soeur = f.replace(/\.(test|spec)(\.[cm]?[jt]sx?)$/, '.circuit-avant.$1$2');
2066
+ if (soeur === f) continue;
2067
+ writeFileSync(path.join(ctx.copie, soeur), r.stdout);
2068
+ out.set(f, soeur);
2069
+ }
2070
+ return out;
2071
+ }
2072
+
2073
+ // The log of the task <t> of a summary of Turborepo, its file from the root of the copy: its path,
2074
+ // and its first lines, the task's own output.
2075
+ function journalDeTache(ctx, t) {
2076
+ if (!t.journal) return { chemin: null, debut: '' };
2077
+ const chemin = path.join(ctx.copie, t.journal);
2078
+ return { chemin, debut: sansCouleurs(lire(chemin)).trim().split('\n').slice(0, 80).join('\n') };
2079
+ }
2080
+
2081
+ // The measure of the proof of the red on <composant>: one run per package, `npm test -- --force
2082
+ // <files>` of the lot that lie in it, HEAD's version of each beside it, or, with no file of the lot in the
2083
+ // component, `npm test -- <package>` for each of its packages, played whole. Each run `{ groupe,
2084
+ // entier, avant, code, resultats, constructions }`. A measure on the same tree (the copy's
2085
+ // fingerprint, `empreinte`) is not played again, as Turborepo replays a task whose inputs did not
2086
+ // move: the last one is kept in `ctx.mesureDeLaPreuve`.
2087
+ function mesureDeLaPreuve(ctx, composant, liste, fichiers) {
2088
+ const cle = `${composant}\0${empreinte(ctx, '.')}`;
2089
+ if (ctx.mesureDeLaPreuve?.cle === cle) return ctx.mesureDeLaPreuve.runs;
2090
+ const index = ctx.composants.indexOf(composant);
2091
+ const entiers = fichiers.length ? [] : paquetsDesComposants(ctx, liste, i => i === index);
2092
+ const groupes = new Map(entiers.map(d => [d, []]));
2093
+ for (const f of fichiers) {
2094
+ const p = paquetDe(liste, f);
2095
+ groupes.set(p, [...(groupes.get(p) ?? []), f]);
2096
+ }
2097
+ const runs = [];
2098
+ for (const [dossier, groupe] of groupes) {
2099
+ const entier = entiers.includes(dossier);
2100
+ const avant = versionsDAvant(ctx, groupe);
2101
+ let r;
2102
+ try {
2103
+ // The files of the lot play for real (`--force`): a run replayed from Turborepo's cache, the
2104
+ // checks' same command on the same inputs, writes no report of Vitest.
2105
+ r = npm(ctx, 'preuve', composant, [
2106
+ 'test',
2107
+ '--',
2108
+ ...(entier ? [dossier] : ['--force']),
2109
+ ...groupe,
2110
+ ...avant.values(),
2111
+ ]);
2112
+ } finally {
2113
+ for (const soeur of avant.values()) rmSync(path.join(ctx.copie, soeur), { force: true });
2114
+ }
2115
+ runs.push({
2116
+ groupe,
2117
+ entier,
2118
+ avant,
2119
+ code: r.code,
2120
+ resultats: lireRapportsVitest(r.dossier),
2121
+ // The builds of the component's packages that failed: each one a red, not compiling is
2122
+ // failing.
2123
+ constructions: constructionsEchouees(ctx, liste, r.dossier, index),
2124
+ });
2125
+ }
2126
+ ctx.mesureDeLaPreuve = { cle, runs };
2127
+ return runs;
2128
+ }
2129
+
2130
+ // The proof of the red on <composant>: `{ ok, rouges, texte, resume }`, `texte` without the list
2131
+ // of the justified greens, which only the whole report, written to its file, holds. A green test
2132
+ // passes by the expected greens of the sheets of the testwriter of <composant> (`verts_attendus`),
2133
+ // read at each proof; the measure plays again only when the copy changed (`mesureDeLaPreuve`).
2134
+ function preuve(ctx, composant) {
2135
+ const fichiers = testsDuComposant(ctx, composant);
2136
+ const verts = fichesDe(ctx, 'testwriter', composant).flatMap(f => f.verts_attendus ?? []);
2137
+ const raisonDuTest = (f, a) =>
2138
+ verts.find(v => v.fichier === f && v.test && (v.test === a.nom || v.test === a.titre))
2139
+ ?.raison ?? null;
2140
+ const duFichier = f => verts.find(v => v.fichier === f && !v.test) ?? null;
2141
+ const rouges = [];
2142
+ // The reds of the files of the lot, apart from the existing ones and the builds.
2143
+ let rougesDuLot = 0;
2144
+ const justifies = [];
2145
+ const injustifies = [];
2146
+ const casses = [];
2147
+ const neufs = [];
2148
+ const liste = paquets(ctx.copie).filter(p => !p.hors);
2149
+ let anciens = 0;
2150
+ const reportes = [];
2151
+ const mesure = mesureDeLaPreuve(ctx, composant, liste, fichiers);
2152
+ for (const { groupe, entier, avant, code, resultats, constructions } of mesure) {
2153
+ for (const { nom, erreur } of constructions) {
2154
+ if (reportes.includes(nom)) continue;
2155
+ rouges.push(`construction de ${nom} — ${erreur}`);
2156
+ reportes.push(nom);
2157
+ }
2158
+ // The files of a package played whole, out of the lot, which HEAD holds green: a red of theirs
2159
+ // is a regression of the lot.
2160
+ if (entier) {
2161
+ for (const res of resultats) {
2162
+ const f = path.relative(ctx.copie, res.fichier);
2163
+ if (res.tests.length === 0 && res.etat === 'failed' && !constructions.length) {
2164
+ rouges.push(
2165
+ `rouge existant : ${f} — ne se charge pas : ${premiereLigne(res.message || res.etat)}`
2166
+ );
2167
+ }
2168
+ for (const a of res.tests.filter(x => x.etat === 'failed')) {
2169
+ rouges.push(`rouge existant : ${f} › ${a.nom} — ${premiereLigne(a.message)}`);
2170
+ }
2171
+ }
2172
+ }
2173
+ for (const f of groupe) {
2174
+ const res = resultats.find(t => t.fichier === path.join(ctx.copie, f));
2175
+ const tests = res?.tests ?? [];
2176
+ // The green tests of HEAD's version of the file, by their full name.
2177
+ const deHead = new Set(
2178
+ (
2179
+ resultats.find(t => avant.has(f) && t.fichier === path.join(ctx.copie, avant.get(f)))
2180
+ ?.tests ?? []
2181
+ )
2182
+ .filter(a => a.etat === 'passed')
2183
+ .map(a => a.nom)
2184
+ );
2185
+ if (constructions.length && (!res || tests.length === 0)) {
2186
+ // It cannot load before the build: it plays at the checks.
2187
+ } else if (!res) {
2188
+ casses.push(
2189
+ `${f} — ne se charge pas : non joué (code ${code}) ; le journal du ticket : ${ctx.journal}`
2190
+ );
2191
+ } else if (tests.length === 0) {
2192
+ casses.push(`${f} — ne se charge pas : ${premiereLigne(res.message || res.etat)}`);
2193
+ }
2194
+ // The expected green of the whole file justifies its greens only for a file HEAD holds.
2195
+ const duF = duFichier(f);
2196
+ if (duF && !avant.has(f)) {
2197
+ neufs.push(
2198
+ `${f} — fichier neuf, que HEAD ne tient pas : son vert attendu sans test ne justifie aucun de ses verts`
2199
+ );
2200
+ }
2201
+ for (const a of tests) {
2202
+ if (a.etat === 'failed') {
2203
+ rouges.push(`${f} › ${a.nom} — ${premiereLigne(a.message)}`);
2204
+ rougesDuLot++;
2205
+ } else if (a.etat === 'passed' && deHead.has(a.nom)) {
2206
+ anciens++;
2207
+ } else if (a.etat === 'passed') {
2208
+ const raison = raisonDuTest(f, a);
2209
+ if (raison) {
2210
+ justifies.push(`${f} › ${a.nom} — ${raison}`);
2211
+ } else if (duF && avant.has(f)) {
2212
+ justifies.push(`${f} › ${a.nom} — ${duF.raison} (par le vert attendu du fichier)`);
2213
+ } else {
2214
+ injustifies.push(`${f} › ${a.nom}`);
2215
+ }
2216
+ }
2217
+ }
2218
+ }
2219
+ }
2220
+ const section = (titre, l) =>
2221
+ l.length ? [`${titre} (${l.length}) :`, ...l.map(x => `- ${x}`), ''] : [];
2222
+ const ok =
2223
+ (fichiers.length > 0 || rouges.length > 0) &&
2224
+ casses.length === 0 &&
2225
+ injustifies.length === 0 &&
2226
+ rouges.length + justifies.length + anciens > 0;
2227
+ const resume = `${rouges.length} rouge(s), ${justifies.length} vert(s) justifié(s), ${injustifies.length} vert(s) sans vert attendu, ${casses.length} fichier(s) qui ne se charge(nt) pas, ${anciens} test(s) vert(s) que HEAD tient déjà, laissé(s) de côté`;
2228
+ const ecrit = (...sections) =>
2229
+ [
2230
+ `${SUITE.find(e => e.etat === PREUVE).titre} sur ${composant} : ${resume}.`,
2231
+ ...[...new Set(reportes)].map(
2232
+ nom => `Les tests de ${nom} se jouent aux contrôles, après le développeur.`
2233
+ ),
2234
+ '',
2235
+ ...(fichiers.length || rouges.length
2236
+ ? []
2237
+ : [`Aucun fichier de test du lot dans ${composant} : rien ne prouve le rouge.`, '']),
2238
+ ...section('Rouges', rouges),
2239
+ ...sections,
2240
+ ...section(
2241
+ 'Verts sans vert attendu (un test vert avant le code passe seulement quand ta fiche le nomme dans `verts_attendus` : `fichier` depuis la racine, `test` son nom complet tel que ce rapport l’écrit après « › », et `raison` ; tous les verts d’un fichier de tests que HEAD tient passent par `fichier` et `raison` seuls)',
2242
+ injustifies
2243
+ ),
2244
+ ...section(
2245
+ 'Verts attendus sans test qui ne justifient rien (ils valent seulement pour un fichier de tests que HEAD tient)',
2246
+ neufs
2247
+ ),
2248
+ ...section('Fichiers qui ne prouvent rien', casses),
2249
+ ]
2250
+ .join('\n')
2251
+ .trim();
2252
+ let n = 1;
2253
+ while (existsSync(`${ctx.copie}.preuve-${n}.md`)) n++;
2254
+ const complet = `${ctx.copie}.preuve-${n}.md`;
2255
+ writeFileSync(complet, `${ecrit(...section('Verts justifiés', justifies))}\n`);
2256
+ const texte = ecrit(
2257
+ `Le rapport entier, avec les verts justifiés et leurs raisons : ${complet}`,
2258
+ ''
2259
+ );
2260
+ return {
2261
+ ok,
2262
+ rouges: rouges.length,
2263
+ texte,
2264
+ resume,
2265
+ resultat: {
2266
+ ok,
2267
+ rouges: rouges.length,
2268
+ justifies: justifies.length,
2269
+ sans: injustifies.length,
2270
+ casses: casses.length,
2271
+ anciens,
2272
+ vide: rougesDuLot + justifies.length + injustifies.length + casses.length === 0,
2273
+ },
2274
+ };
2275
+ }
2276
+
2277
+ // The builds that failed in the run whose results are under <dossier> (the summary of Turborepo,
2278
+ // task `<package>#build`) of a package of the component at <index>: `{ nom, erreur }`, `erreur`
2279
+ // its log file and the beginning of its own output.
2280
+ function constructionsEchouees(ctx, liste, dossier, index) {
2281
+ return lireResumes(dossier)
2282
+ .filter(t => t.script === 'build' && t.code !== null && t.code !== 0)
2283
+ .flatMap(t => {
2284
+ const p = liste.find(x => x.pkg.name === t.paquet);
2285
+ if (!p || proprietaire(ctx, p.dossier) !== index) return [];
2286
+ const j = journalDeTache(ctx, t);
2287
+ return [{ nom: t.paquet, erreur: `son journal : ${j.chemin}\n\n${j.debut}\n` }];
2288
+ });
2289
+ }
2290
+
2291
+ // The component of the run that owns <chemin> (from the root of the copy): `{ proprietaire,
2292
+ // horsTitre }`, the index of that component or -1, and the name of the package of <liste> that
2293
+ // holds the path when no component of the title does.
2294
+ function situer(ctx, liste, chemin) {
2295
+ const i = chemin ? proprietaire(ctx, chemin) : -1;
2296
+ const p = i < 0 && chemin ? liste.find(x => chemin.startsWith(`${x.dossier}/`)) : null;
2297
+ return { proprietaire: i, horsTitre: p ? p.pkg.name : null };
2298
+ }
2299
+
2300
+ // The reds of the check <c> (`{ args, env, code, dossier }`), read in its results: each failed
2301
+ // test of the reports of Vitest, each finding of the guards' SARIF, each failed build, and each
2302
+ // failed task none of those situates; a red check with none of them is one red of the command.
2303
+ // Each red `{ texte, fichier, gardes, detail, proprietaire, horsTitre, arbitrer }`: the line that
2304
+ // names it (test or finding, file, message), its file (null for a red of a task), the names its
2305
+ // finding carries (its guard and its rule), the beginning of its
2306
+ // task's log for a build, the index of the
2307
+ // component that owns it (-1 for none: the root, or a red without a file), the package out of the
2308
+ // title that holds it, and whether it is a finding to settle (`a-arbitrer`).
2309
+ function rougesDe(ctx, liste, c) {
2310
+ if (c.code === 0 || c.code === A_REPASSER) return [];
2311
+ const out = [];
2312
+ const testes = new Set();
2313
+ for (const res of lireRapportsVitest(c.dossier)) {
2314
+ const f = path.relative(ctx.copie, res.fichier);
2315
+ const lieu = situer(ctx, liste, f);
2316
+ if (res.tests.length === 0 && res.etat === 'failed') {
2317
+ out.push({
2318
+ texte: `test ${f} — ne se charge pas : ${premiereLigne(res.message)}`,
2319
+ fichier: f,
2320
+ ...lieu,
2321
+ });
2322
+ testes.add(paquetDe(liste, f));
2323
+ }
2324
+ for (const a of res.tests.filter(x => x.etat === 'failed')) {
2325
+ out.push({
2326
+ texte: `test ${f} › ${a.nom} — ${premiereLigne(a.message)}`,
2327
+ fichier: f,
2328
+ ...lieu,
2329
+ });
2330
+ testes.add(paquetDe(liste, f));
2331
+ }
2332
+ }
2333
+ const { constats } = lireSarif(c.dossier);
2334
+ for (const k of constats) {
2335
+ const ou = k.fichier ? ` ${k.fichier}${k.ligne ? `:${k.ligne}` : ''}` : '';
2336
+ out.push({
2337
+ texte: `constat ${k.garde}${ou} — ${k.message}${k.genre === 'a-arbitrer' ? ' (à arbitrer)' : ''}`,
2338
+ fichier: k.fichier ?? null,
2339
+ gardes: [k.garde, k.regle].filter(Boolean),
2340
+ ...situer(ctx, liste, k.fichier),
2341
+ arbitrer: k.genre === 'a-arbitrer',
2342
+ });
2343
+ }
2344
+ // The guards of the project wrote findings when one comes from a guard out of the frame's.
2345
+ const duProjet = constats.some(k => !NOMS_DES_GARDES.includes(k.garde));
2346
+ for (const t of lireResumes(c.dossier)) {
2347
+ if (t.code === null || t.code === 0 || t.code === A_REPASSER) continue;
2348
+ if (t.script === 'test:vitest' && testes.has(t.dossier)) continue;
2349
+ if (t.script === 'grillhouse:gardes' && constats.length) continue;
2350
+ if (t.script === 'gardes' && duProjet) continue;
2351
+ const p = liste.find(x => x.pkg.name === t.paquet);
2352
+ const lieu = p ? situer(ctx, liste, `${p.dossier}/`) : { proprietaire: -1, horsTitre: null };
2353
+ if (p && lieu.proprietaire < 0) lieu.horsTitre = p.pkg.name;
2354
+ const j = journalDeTache(ctx, t);
2355
+ out.push({
2356
+ texte:
2357
+ t.script === 'build'
2358
+ ? `construction de ${t.paquet} — son journal : ${j.chemin}`
2359
+ : `tâche ${t.tache} rouge (code ${t.code}) — son journal : ${j.chemin}`,
2360
+ detail: t.script === 'build' ? j.debut : '',
2361
+ ...lieu,
2362
+ });
2363
+ }
2364
+ if (out.length === 0) {
2365
+ out.push({
2366
+ texte: `${ligneDeCommande(c)} rouge (code ${c.code}), sans résultat qui situe son rouge — le journal du ticket : ${ctx.journal}`,
2367
+ proprietaire: -1,
2368
+ horsTitre: null,
2369
+ });
2370
+ }
2371
+ return out;
2372
+ }
2373
+
2374
+ const ligneDeCommande = c =>
2375
+ `${Object.entries(c.env ?? {})
2376
+ .map(([k, v]) => `${k}=${v} `)
2377
+ .join('')}npm ${c.args.join(' ')}`;
2378
+
2379
+ // The checks after the developpeur of the component at <index>: its own, or, when <finale>, `npm
2380
+ // run verifier`, what the integration plays, restricted by <restreinte> (`{ avant, apres }`, two
2381
+ // lots kept) to the packages that changed from one to the other and their dependents, with the
2382
+ // root's tasks, the guards (`--filter=...[<avant>...<apres>] --filter=//`). `{ rouge, aRepasser,
2383
+ // arbitrer, texte, complet, resume, proprietaire, horsTitre, lieux, faits }`: `texte` the report for the
2384
+ // developpeur the red goes to, its own reds first; `complet` every red, for a stop; `proprietaire`
2385
+ // the index of the component that owns the red, -1 for none; `horsTitre` the packages out of the
2386
+ // title that hold a red; `arbitrer` when every red of that developpeur is a finding to settle;
2387
+ // `lieux` the place of each red, `{ fichier, gardes }`, its file (null for a red without one) and
2388
+ // the names its finding carries (none for a test or a task); `faits` the facts of a stop on
2389
+ // these checks.
2390
+ function controles(ctx, index, finale, restreinte = null) {
2391
+ const composant = ctx.composants[index];
2392
+ const liste = paquets(ctx.copie).filter(p => !p.hors);
2393
+ const filtres = restreinte
2394
+ ? ['--', `--filter=...[${restreinte.avant}...${restreinte.apres}]`, '--filter=//']
2395
+ : [];
2396
+ const commandes = finale
2397
+ ? [{ args: ['run', 'verifier', ...filtres] }]
2398
+ : commandesDuComposant(ctx, index, liste);
2399
+ const resultats = commandes.map(({ args, env }) => {
2400
+ let r = npm(ctx, 'controles', composant, args, env);
2401
+ // verifier plays a task at 75 again itself, at rest: its 75 is final.
2402
+ if (r.code === A_REPASSER && !finale) r = npm(ctx, 'controles', composant, args, env);
2403
+ const c = { args, env, ...r };
2404
+ return { ...c, rouges: rougesDe(ctx, liste, c) };
2405
+ });
2406
+ const tous = resultats.flatMap(r => r.rouges);
2407
+ // Out of the final pass, the reds of this component are its own; the others (another component,
2408
+ // the root, a red no file situates, a finding of a guard on a file of another package, a package
2409
+ // out of the title) wait for it.
2410
+ const siens = r => finale || r.proprietaire === index;
2411
+ const rouges = tous.filter(siens);
2412
+ const laisses = tous.filter(r => !siens(r));
2413
+ const aRepasser = resultats.filter(r => r.code === A_REPASSER);
2414
+ const nommer = r => `${ligneDeCommande(r)} (code ${r.code})`;
2415
+ const enEchec = resultats.filter(r => r.code !== 0 && r.code !== A_REPASSER);
2416
+ // The owner of the red: in the final pass the first component, provider first, that owns one;
2417
+ // out of it, this one.
2418
+ const nommes = rouges.map(r => r.proprietaire).filter(i => i >= 0);
2419
+ const proprio = !rouges.length ? -1 : finale ? (nommes.length ? Math.min(...nommes) : -1) : index;
2420
+ const a = finale ? rouges.filter(r => r.proprietaire === proprio && proprio >= 0) : rouges;
2421
+ const autres = rouges.filter(r => !a.includes(r));
2422
+ const resume = rouges.length
2423
+ ? `rouge : ${rouges.length} rouge(s) — ${enEchec.map(nommer).join(' ; ')}`
2424
+ : aRepasser.length
2425
+ ? `à repasser : ${aRepasser.map(nommer).join(' ; ')}`
2426
+ : `verts : ${resultats.map(ligneDeCommande).join(' ; ')}${
2427
+ laisses.length
2428
+ ? ` ; laissés à la passe finale, ${laisses.length} rouge(s) hors de ${composant}`
2429
+ : ''
2430
+ }`;
2431
+ // Where the checks played: the final pass out of the envelope, on the whole copy or restricted
2432
+ // to what changed since the last review; a component's checks, its tests and packages, its
2433
+ // guards in its envelope.
2434
+ const ou = !finale
2435
+ ? `Contrôles du composant ${composant}, ses tests et ses paquets, ses gardes dans son enveloppe`
2436
+ : restreinte
2437
+ ? `Contrôles de la passe finale, hors enveloppe, restreinte à ce que le lot change depuis la relecture d'avant et à ses dépendants (après ${composant})`
2438
+ : `Contrôles de la passe finale, hors enveloppe, sur la copie entière (après ${composant})`;
2439
+ const ligne = r => [`- ${r.texte}`, ...(r.detail ? ['', r.detail, ''] : [])];
2440
+ const nomDuProprio = r =>
2441
+ r.proprietaire >= 0 ? ctx.composants[r.proprietaire] : (r.horsTitre ?? 'hors des composants');
2442
+ const journal = `Le journal du ticket, pour le détail des commandes : ${ctx.journal}`;
2443
+ const texte = [
2444
+ `${ou} : ${resume}.`,
2445
+ '',
2446
+ ...(a.length ? [`Tes rouges (${a.length}) :`, ...a.flatMap(ligne), ''] : []),
2447
+ ...(aRepasser.length && !rouges.length
2448
+ ? [`À repasser (${aRepasser.length}) : ${aRepasser.map(nommer).join(' ; ')}`, '']
2449
+ : []),
2450
+ ...(autres.length
2451
+ ? [`Les autres rouges (${autres.length}) vont aux agents de leurs composants.`, '']
2452
+ : []),
2453
+ ...(laisses.length && !finale
2454
+ ? [`Laissés à la passe finale (${laisses.length}) :`, ...laisses.map(r => `- ${r.texte}`), '']
2455
+ : []),
2456
+ journal,
2457
+ ].join('\n');
2458
+ const sansListe = [`${ou} : ${resume}.`, '', journal].join('\n');
2459
+ const complet = [
2460
+ `${ou} : ${resume}.`,
2461
+ '',
2462
+ ...(rouges.length
2463
+ ? [
2464
+ `Les rouges (${rouges.length}) :`,
2465
+ ...rouges.flatMap(r => ligne({ ...r, texte: `${nomDuProprio(r)} : ${r.texte}` })),
2466
+ '',
2467
+ ]
2468
+ : []),
2469
+ journal,
2470
+ ].join('\n');
2471
+ return {
2472
+ rouge: rouges.length > 0,
2473
+ aRepasser: aRepasser.length > 0,
2474
+ arbitrer: a.length > 0 && a.every(r => r.arbitrer),
2475
+ texte,
2476
+ complet,
2477
+ sansListe,
2478
+ // The reds of the stop, each with its component: this component's and those before it, or at
2479
+ // the final pass all of them; never those left to the final pass.
2480
+ rouges: rouges.map(r => ({ composant: nomDuProprio(r), texte: r.texte, detail: r.detail })),
2481
+ resume,
2482
+ proprietaire: proprio,
2483
+ horsTitre: [...new Set(rouges.map(r => r.horsTitre).filter(Boolean))].sort(),
2484
+ lieux: rouges.map(r => ({ fichier: r.fichier ?? null, gardes: r.gardes ?? [] })),
2485
+ faits: {
2486
+ rouges: [...new Set(rouges.map(nomDuProprio))].sort(),
2487
+ aRepasser: aRepasser.length > 0,
2488
+ },
2489
+ };
2490
+ }
2491
+
2492
+ // The checks of the component at <index>, out of the final pass: `npm test -- <test files of the
2493
+ // lot>` that lie in it (or in the shared test material that reads only the components up to it),
2494
+ // `npm test -- <package>` for each of its packages and each of its the lot touches, then the
2495
+ // guards in the envelope of the component, whose findings on its own files alone count.
2496
+ function commandesDuComposant(ctx, index, liste) {
2497
+ const lot = changements(ctx);
2498
+ const touches = liste.length
2499
+ ? [...new Set(lot.map(c => paquetDe(liste, c.fichier)).filter(d => d !== '.'))].sort()
2500
+ : ['.'];
2501
+ const sien = chemin => proprietaire(ctx, chemin) === index;
2502
+ const tests = testsDuLot(ctx).filter(f => sien(f) || matiereFaite(ctx, liste, f, index));
2503
+ const scripts = lireJson(path.join(ctx.copie, 'package.json'))?.scripts ?? {};
2504
+ const enveloppe = { [VARIABLE_ENVELOPPE]: ctx.composants[index] };
2505
+ return [
2506
+ ...(tests.length ? [{ args: ['test', '--', ...tests] }] : []),
2507
+ ...[
2508
+ ...new Set([...touches.filter(sien), ...paquetsDesComposants(ctx, liste, i => i === index)]),
2509
+ ]
2510
+ .sort()
2511
+ .map(d => ({ args: d === '.' ? ['test'] : ['test', '--', d] })),
2512
+ ...['grillhouse:gardes', 'gardes']
2513
+ .filter(s => s in scripts)
2514
+ .map(s => ({ args: ['run', s], env: enveloppe })),
2515
+ ];
2516
+ }
2517
+
2518
+ // The directories of the packages of <liste> that play tests (a script `test:vitest`) and whose
2519
+ // component, by its index, <garde> keeps; sorted.
2520
+ function paquetsDesComposants(ctx, liste, garde) {
2521
+ return liste
2522
+ .filter(p => p.pkg.scripts?.['test:vitest'])
2523
+ .filter(p => {
2524
+ const i = proprietaire(ctx, p.dossier);
2525
+ return i >= 0 && garde(i);
2526
+ })
2527
+ .map(p => p.dossier)
2528
+ .sort();
2529
+ }
2530
+
2531
+ // Whether the component at <index> carries no test: in a repository of packages, no package of the
2532
+ // frame with a script `test:vitest` lies in it or holds it, as `npm test -- <chemin>`
2533
+ // (scripts/tests.mjs) refuses a path out of every package or in a package without that script. A
2534
+ // repository without packages plays its tests at its root: each component has them.
2535
+ function sansTests(ctx, index) {
2536
+ const liste = paquets(existsSync(ctx.copie) ? ctx.copie : ctx.racine).filter(p => !p.hors);
2537
+ if (liste.length === 0) return false;
2538
+ const c = ctx.composants[index];
2539
+ const touche = d => c === '.' || d === c || d.startsWith(`${c}/`) || c.startsWith(`${d}/`);
2540
+ return !liste.some(p => typeof p.pkg.scripts?.['test:vitest'] === 'string' && touche(p.dossier));
2541
+ }
2542
+
2543
+ // Whether the copy holds a change of <composant> against its HEAD.
2544
+ function livre(ctx, composant) {
2545
+ const r = spawnSync(
2546
+ 'git',
2547
+ ['-C', ctx.copie, 'status', '--porcelain', '--untracked-files=all', '--', composant],
2548
+ { encoding: 'utf8' }
2549
+ );
2550
+ return String(r.stdout ?? '').trim() !== '';
2551
+ }
2552
+
2553
+ // The fingerprint of the files of <composant> in the copy: `git status --porcelain` of its
2554
+ // directory and the content of each file it lists.
2555
+ function empreinte(ctx, composant) {
2556
+ const r = spawnSync(
2557
+ 'git',
2558
+ ['-C', ctx.copie, 'status', '--porcelain', '-z', '--untracked-files=all', '--', composant],
2559
+ { encoding: 'utf8' }
2560
+ );
2561
+ const statut = String(r.stdout ?? '');
2562
+ const h = createHash('sha256').update(statut);
2563
+ for (const e of statut.split('\0')) {
2564
+ if (e.length < 4) continue;
2565
+ const f = path.join(ctx.copie, e.slice(3));
2566
+ try {
2567
+ if (statSync(f).isFile()) h.update(e).update(readFileSync(f));
2568
+ } catch {
2569
+ // A file removed: its line in the status is its trace.
2570
+ }
2571
+ }
2572
+ return h.digest('hex');
2573
+ }
2574
+
2575
+ // The index of the component of the run that holds <chemin> (relative to the copy), the deepest
2576
+ // one, `.` holding every path; -1 for none.
2577
+ function proprietaire(ctx, chemin) {
2578
+ let trouve = -1;
2579
+ let profondeur = -1;
2580
+ ctx.composants.forEach((c, i) => {
2581
+ const dedans = c === '.' || chemin === c || chemin.startsWith(`${c}/`);
2582
+ const p = c === '.' ? 0 : c.split('/').length;
2583
+ if (dedans && p > profondeur) {
2584
+ trouve = i;
2585
+ profondeur = p;
2586
+ }
2587
+ });
2588
+ return trouve;
2589
+ }
2590
+
2591
+ // True when <fichier> lies in a package of the shared test material that depends on no component
2592
+ // after <index>: a test it holds then reads only what is done.
2593
+ function matiereFaite(ctx, liste, fichier, index) {
2594
+ const m = matieres(ctx.copie)
2595
+ .map(x => path.relative(ctx.copie, x.dir))
2596
+ .find(d => fichier.startsWith(`${d}/`));
2597
+ if (!m) return false;
2598
+ const pkg =
2599
+ liste.find(p => p.dossier === m)?.pkg ??
2600
+ lireJson(path.join(ctx.copie, m, 'package.json')) ??
2601
+ {};
2602
+ const noms = Object.keys({
2603
+ ...pkg.dependencies,
2604
+ ...pkg.devDependencies,
2605
+ ...pkg.peerDependencies,
2606
+ });
2607
+ return noms.every(n => {
2608
+ const p = liste.find(x => x.pkg.name === n);
2609
+ return !p || proprietaire(ctx, p.dossier) <= index;
2610
+ });
2611
+ }
2612
+
2613
+ const lireJson = f => {
2614
+ try {
2615
+ return JSON.parse(readFileSync(f, 'utf8'));
2616
+ } catch {
2617
+ return null;
2618
+ }
2619
+ };
2620
+
2621
+ // Where the run goes on from the component at <index> to <dernier>: `{ index, etape, tentative,
2622
+ // rapport, finale }`. Out of a round, that component's testwriter. In a round of the reviewer's
2623
+ // findings (or of `--depuis`), the first component from <index> on that the round names, at the
2624
+ // step and with the findings it names, or that the circuit has not reached yet (`atteints`), from
2625
+ // its testwriter; a component it has reached and the round does not name plays nothing, as Bazel
2626
+ // plays only the targets a change affects: the final pass judges it. Past them, the final pass.
2627
+ function suivant(ctx, index, dernier) {
2628
+ for (let i = index; i <= dernier; i++) {
2629
+ const c = ctx.composants[i];
2630
+ const r = ctx.renvois?.[c];
2631
+ if (r) return { index: i, etape: r.etape, tentative: 1, rapport: r[r.etape], finale: false };
2632
+ if (!ctx.renvois || !ctx.atteints.includes(c)) {
2633
+ return { index: i, etape: TESTS, tentative: 1, rapport: null, finale: false };
2634
+ }
2635
+ }
2636
+ return { index: dernier, etape: CONTROLES, tentative: 0, rapport: null, finale: true };
2637
+ }
2638
+
2639
+ // The component <composant>, whose work the circuit has carried through (its proof without a red
2640
+ // for its developpeur, its developpeur's delivery, its green checks, the final pass green), kept
2641
+ // among `atteints`: a component, each on its own, whatever the title gains before it.
2642
+ function atteindre(ctx, composant) {
2643
+ if (!ctx.atteints.includes(composant)) ctx.atteints.push(composant);
2644
+ }
2645
+
2646
+ // The RENDU verdicts since the ticket's last ACCEPTÉ: a lot the queue sent back starts a new count.
2647
+ function rendusDepuisAccepte(ctx) {
2648
+ const verdicts = fichesDe(ctx, 'relecteur').map(f => f.verdict);
2649
+ return verdicts.length - (verdicts.lastIndexOf('ACCEPTÉ') + 1);
2650
+ }
2651
+
2652
+ // The counts of the bounded loops that run at the step <etape> under way, by their key
2653
+ // (scripts/etapes.mjs, `boucles`): the session of the testwriter at its tests and its proof, of the
2654
+ // developpeur at its code and its checks, the review; null at a step without one.
2655
+ function boucles(ctx, etape, tentative, finale) {
2656
+ const compte = {
2657
+ tests: () => tentative,
2658
+ code: () => tentative,
2659
+ relecture: () => rendusDepuisAccepte(ctx) + 1,
2660
+ };
2661
+ const e = etapeDuCircuit({ etape, finale });
2662
+ const l = e ? bouclesDe(e).filter(b => compte[b.cle]) : [];
2663
+ return l.length ? Object.fromEntries(l.map(b => [b.cle, compte[b.cle]()])) : null;
2664
+ }
2665
+
2666
+ // The circuit from <debut> (where `depart` starts it) to the entry of the lot in the queue.
2667
+ async function derouler(ctx, debut) {
2668
+ let { index, etape, tentative, rapport, dernier, finale } = debut;
2669
+ // The step a `--reprendre` starts at, the first of the loop.
2670
+ let reprise = Boolean(ctx.reprendre);
2671
+ for (;;) {
2672
+ const composant = ctx.composants[index];
2673
+ const reprend = reprise;
2674
+ reprise = false;
2675
+ // The checks of the last component are the final pass: the state says so from its start.
2676
+ const enFinale = finale || (etape === CONTROLES && index === dernier);
2677
+ const etat = {
2678
+ etape,
2679
+ composant,
2680
+ tentative,
2681
+ code: null,
2682
+ raison: null,
2683
+ rapport,
2684
+ dernier: ctx.composants[dernier],
2685
+ finale: enFinale,
2686
+ boucles: boucles(ctx, etape, tentative, enFinale),
2687
+ };
2688
+ // A step the circuit leads itself names its start in the state while it runs, and writes its
2689
+ // line to the register at its end, whatever its outcome.
2690
+ const menee = MENEES.has(etape) ? { etape, composant, debut: new Date().toISOString() } : null;
2691
+ ecrireEtat(ctx, { ...etat, mene: menee?.debut ?? null });
2692
+ try {
2693
+ if (etape === TESTS && sansTests(ctx, index)) {
2694
+ commenter(
2695
+ ctx,
2696
+ composant,
2697
+ 'testwriter',
2698
+ 'aucun test dans ce composant (aucun paquet avec un script test:vitest ne le tient) : ni testwriter ni preuve, son développeur directement.'
2699
+ );
2700
+ rapport = [
2701
+ `Le composant ${composant} ne porte aucun test (aucun paquet avec un script test:vitest ne le tient) : le circuit le lance sans testwriter ni preuve. Tu écris sa part du ticket d'après ses critères ; les contrôles suivent ta séance.`,
2702
+ rapport,
2703
+ ]
2704
+ .filter(Boolean)
2705
+ .join('\n\n');
2706
+ etape = CODE;
2707
+ tentative = 1;
2708
+ } else if (etape === TESTS) {
2709
+ const fiche = await seance(ctx, 'testwriter', composant, {
2710
+ tentative,
2711
+ rapport,
2712
+ derniere: tentative > RELANCES_TESTWRITER,
2713
+ });
2714
+ // A testwriter who declares himself blocked or in need of an answer goes to the regulator.
2715
+ if (fiche.statut !== 'FAIT')
2716
+ throw arretDuStatut(ctx, 'testwriter', composant, tentative, fiche);
2717
+ commenter(ctx, composant, 'testwriter', `séance ${tentative} finie, sa fiche lue.`);
2718
+ etape = PREUVE;
2719
+ } else if (etape === PREUVE) {
2720
+ const p = preuve(ctx, composant);
2721
+ rapport = p.texte;
2722
+ if (p.ok) {
2723
+ // The reviewer's findings for this developpeur follow the proof: a finding launches him,
2724
+ // red or not.
2725
+ const pourLui = ctx.renvois?.[composant]?.developpeur;
2726
+ const auDeveloppeur = p.rouges > 0 || Boolean(pourLui);
2727
+ const suite = p.rouges
2728
+ ? 'au développeur'
2729
+ : pourLui
2730
+ ? 'aucun rouge ; au développeur, que nomme un constat du relecteur'
2731
+ : 'aucun rouge : un lot de tests seul, aux contrôles';
2732
+ ecrireRapport(
2733
+ ctx,
2734
+ composant,
2735
+ 'preuve',
2736
+ `${p.resume} ; ${suite}.\n\n${p.texte}`,
2737
+ p.resultat
2738
+ );
2739
+ etape = auDeveloppeur ? CODE : CONTROLES;
2740
+ tentative = auDeveloppeur ? 1 : 0;
2741
+ if (pourLui) rapport = `${rapport}\n\nLES CONSTATS DU RELECTEUR :\n\n${pourLui}`;
2742
+ if (!auDeveloppeur) atteindre(ctx, composant);
2743
+ // The reds of a proof are its component's.
2744
+ ctx.rougesDuDeveloppeur = { rouges: [composant] };
2745
+ } else if (tentative <= RELANCES_TESTWRITER) {
2746
+ ecrireRapport(
2747
+ ctx,
2748
+ composant,
2749
+ 'preuve',
2750
+ `${p.resume} ; testwriter relancé (séance ${tentative + 1}).\n\n${p.texte}`,
2751
+ p.resultat
2752
+ );
2753
+ etape = TESTS;
2754
+ tentative++;
2755
+ } else {
2756
+ throw new Arret('preuve', p.texte, { resultat: p.resultat });
2757
+ }
2758
+ } else if (etape === CODE) {
2759
+ const fort = tentative >= SEANCES_DEVELOPPEUR;
2760
+ const avant = empreinte(ctx, composant);
2761
+ const fiche = await seance(ctx, 'developpeur', composant, {
2762
+ tentative,
2763
+ rapport,
2764
+ fort,
2765
+ derniere: fort,
2766
+ });
2767
+ // A question goes to the regulator; a red out of reach, to the testwriter its sheet sends it
2768
+ // to, else to the regulator.
2769
+ if (fiche.statut === 'BESOIN') {
2770
+ throw arretDuStatut(ctx, 'developpeur', composant, tentative, fiche);
2771
+ }
2772
+ const renvoi = renvoiAuTestwriter(ctx, fiche, index);
2773
+ if (renvoi) {
2774
+ // The red that remains lies in the tests of that component: the gesture of the table of
2775
+ // the stops, its testwriter, as a regulator's `RELANCE --depuis testwriter <composant>`, the
2776
+ // handover as its context.
2777
+ const cible = ctx.composants[renvoi.index];
2778
+ if (ctx.renvoiAuTestwriter === cible) {
2779
+ throw new Arret(
2780
+ 'sans-livraison',
2781
+ `La fiche de la séance ${tentative} du développeur de ${composant} renvoie une deuxième fois de suite à \`testwriter ${cible}\`, sans contrôles verts depuis le premier renvoi : le testwriter n'a pas levé ce rouge.\n\n${renvoi.texte}`
2782
+ );
2783
+ }
2784
+ ctx.renvoiAuTestwriter = cible;
2785
+ commenter(
2786
+ ctx,
2787
+ composant,
2788
+ 'developpeur',
2789
+ `séance ${tentative}${fort ? ' (opus, effort high)' : ''} finie ; sa fiche renvoie le rouge à \`testwriter ${cible}\` : le circuit rejoue ${cible} depuis son testwriter, puis les composants d'après.`
2790
+ );
2791
+ const d = decisionDuGeste(
2792
+ ctx,
2793
+ gesteDe('renvoi-au-testwriter'),
2794
+ renvoi.index,
2795
+ renvoi.texte
2796
+ );
2797
+ ctx.reprendre = false;
2798
+ ctx.depuis = d.depuis;
2799
+ ctx.contexte = null;
2800
+ ({ index, etape, tentative, rapport, dernier, finale } = depart(ctx, null, index));
2801
+ continue;
2802
+ }
2803
+ if (fiche.statut === 'BLOQUÉ') {
2804
+ throw arretDuStatut(ctx, 'developpeur', composant, tentative, fiche);
2805
+ }
2806
+ if (empreinte(ctx, composant) === avant && reprend && livre(ctx, composant)) {
2807
+ // A resumed developpeur that finds its lot delivered: the checks judge it.
2808
+ commenter(
2809
+ ctx,
2810
+ composant,
2811
+ 'developpeur',
2812
+ `séance ${tentative} reprise sans changement, le composant garde la livraison d'avant : aux contrôles.`
2813
+ );
2814
+ inscrire(ctx, { role: AUTEUR_DU_CIRCUIT, etape: CODE, composant, inchange: true });
2815
+ } else if (empreinte(ctx, composant) === avant) {
2816
+ throw new Arret(
2817
+ 'sans-livraison',
2818
+ `La séance ${tentative} du développeur de ${composant} ne change aucun fichier de ${composant} : un développeur n'est lancé que sur un rouge ou un constat du relecteur, et il reste. Sa passation au ticket dit pourquoi :\n\n${passations(ctx).at(-1)?.text ?? 'aucune'}`
2819
+ );
2820
+ }
2821
+ atteindre(ctx, composant);
2822
+ commenter(
2823
+ ctx,
2824
+ composant,
2825
+ 'developpeur',
2826
+ `séance ${tentative}${fort ? ' (opus, effort high)' : ''} finie, sa fiche lue.`
2827
+ );
2828
+ etape = CONTROLES;
2829
+ } else if (etape === CONTROLES) {
2830
+ const passeFinale = enFinale;
2831
+ // The final pass: after a regeneration of the references, restricted to what it changed;
2832
+ // else whole, or restricted to what changed since the last review.
2833
+ const regeneration = passeFinale ? (ctx.regeneration ?? null) : null;
2834
+ ctx.regeneration = null;
2835
+ const passe = !passeFinale
2836
+ ? { arbre: null, restreinte: null }
2837
+ : regeneration
2838
+ ? { arbre: arbreDeLaCopie(ctx), restreinte: regeneration.restreinte }
2839
+ : laPasseFinale(ctx);
2840
+ const k = controles(ctx, index, passeFinale, passe.restreinte);
2841
+ const resultat = issue => ({ issue, finale: passeFinale });
2842
+ rapport = k.texte;
2843
+ // A stop of the checks: <phrase>, then the report, its reds also kept apart.
2844
+ const arreter = (nom, phrase, faits) => {
2845
+ const avant = phrase ? `${phrase}\n\n` : '';
2846
+ throw new Arret(nom, `${avant}${k.complet}`, {
2847
+ rouges: k.rouges,
2848
+ motif: `${avant}${k.sansListe}`,
2849
+ faits,
2850
+ });
2851
+ };
2852
+ // The developpeur a red goes to: this component's, or in the final pass the owner's (the
2853
+ // table of the stops, scripts/etapes.mjs: rouge-d-un-autre-composant).
2854
+ const vers = k.rouge && passeFinale ? k.proprietaire : index;
2855
+ // One count of sessions for the component's checks, and one for the whole final pass,
2856
+ // whichever component its red goes to.
2857
+ const suivante = tentative + 1;
2858
+ // A final pass red on the references the project declares alone: their regeneration.
2859
+ const aRegenerer = k.rouge && passeFinale ? referencesDesRouges(ctx, k.lieux) : [];
2860
+ const noms = aRegenerer.map(r => r.nom).join(', ');
2861
+ if (aRegenerer.length && regeneration) {
2862
+ arreter(
2863
+ 'references',
2864
+ `La passe finale, après la régénération des références (${noms}), reste rouge sur elles : la régénération ne lève pas leurs rouges. La décision revient au régulateur.`,
2865
+ k.faits
2866
+ );
2867
+ } else if (aRegenerer.length) {
2868
+ ecrireRapport(
2869
+ ctx,
2870
+ composant,
2871
+ 'controles',
2872
+ `${k.resume} ; ses rouges sont ceux des références que le projet déclare (${noms}) : à leur régénération.`,
2873
+ resultat('rouge'),
2874
+ k.texte
2875
+ );
2876
+ ctx.entiere = !passe.restreinte;
2877
+ etape = REFERENCES;
2878
+ tentative = 0;
2879
+ finale = true;
2880
+ } else if (k.rouge && passeFinale && k.horsTitre.length) {
2881
+ arreter(
2882
+ 'controles',
2883
+ `La passe finale est rouge dans ${k.horsTitre.join(', ')}, hors des composants du ticket (${ctx.composants.join(', ')}) : aucun de leurs développeurs n'est relancé. La décision revient au régulateur : faire ajouter ce composant au titre du ticket par le superviseur (\`MONTE\`), le rouge allant ensuite à son développeur ; ou faire corriger le lot par le composant qui le cause (\`RELANCE --depuis developpeur <composant>\`, le rouge en \`contexte\`).`,
2884
+ k.faits
2885
+ );
2886
+ } else if (k.rouge && vers < 0) {
2887
+ arreter(
2888
+ 'controles',
2889
+ `Aucun composant du ticket (${ctx.composants.join(', ')}) ne possède ce rouge de la passe finale (un rouge de la racine, ou que rien ne situe) : la décision revient au régulateur.`,
2890
+ k.faits
2891
+ );
2892
+ } else if (k.rouge && k.arbitrer) {
2893
+ arreter(
2894
+ 'controles',
2895
+ `Les rouges de ${ctx.composants[vers]} sont des constats à arbitrer : aucun développeur ne les tranche, la décision revient au régulateur.`,
2896
+ { ...k.faits, genre: 'a-arbitrer' }
2897
+ );
2898
+ } else if (k.rouge && suivante <= SEANCES_DEVELOPPEUR) {
2899
+ const qui = vers === index ? '' : ` de ${ctx.composants[vers]}`;
2900
+ ecrireRapport(
2901
+ ctx,
2902
+ composant,
2903
+ 'controles',
2904
+ `${k.resume} ; développeur${qui} relancé (séance ${suivante}).`,
2905
+ resultat('rouge'),
2906
+ k.texte
2907
+ );
2908
+ etape = CODE;
2909
+ tentative = suivante;
2910
+ index = vers;
2911
+ finale = passeFinale;
2912
+ ctx.rougesDuDeveloppeur = k.faits;
2913
+ } else if (k.rouge) {
2914
+ arreter('controles', null, k.faits);
2915
+ } else if (k.aRepasser) {
2916
+ arreter('a-repasser', null, k.faits);
2917
+ } else {
2918
+ ecrireRapport(ctx, composant, 'controles', `${k.resume}.`, resultat('vert'));
2919
+ // Green checks: a sending of the developpeur to a testwriter may come again.
2920
+ ctx.renvoiAuTestwriter = null;
2921
+ // Green checks carry this component, or at the final pass every one, to their end.
2922
+ for (const c of passeFinale ? ctx.composants : [composant]) atteindre(ctx, c);
2923
+ if (!passeFinale) {
2924
+ ({ index, etape, tentative, rapport, finale } = suivant(ctx, index + 1, dernier));
2925
+ } else {
2926
+ // The whole pass green on this lot; the lot the last review accepted goes to the
2927
+ // integration, any other to the review.
2928
+ // A pass restricted to a regeneration after a whole one counts as whole.
2929
+ if (passe.arbre && (!passe.restreinte || regeneration?.entiere)) {
2930
+ ctx.verifie = passe.arbre;
2931
+ }
2932
+ const r = relectureDAvant(ctx);
2933
+ const accepte =
2934
+ r?.fiche.verdict === 'ACCEPTÉ' && passe.arbre && r.lot.arbre === passe.arbre;
2935
+ etape = accepte ? INTEGRATION : RELECTURE;
2936
+ }
2937
+ }
2938
+ } else if (etape === REFERENCES) {
2939
+ const g = regenerer(ctx, composant);
2940
+ ecrireRapport(ctx, composant, 'references', g.texte, {
2941
+ regenerees: g.regenerees,
2942
+ fichiers: g.fichiers,
2943
+ });
2944
+ ctx.regeneration = { restreinte: g.restreinte, entiere: ctx.entiere === true };
2945
+ ctx.entiere = false;
2946
+ etape = CONTROLES;
2947
+ tentative = 0;
2948
+ finale = true;
2949
+ } else if (etape === RELECTURE) {
2950
+ const v = await relire(ctx, composant);
2951
+ // A question for the owner: nothing is committed nor relaunched before his answer.
2952
+ if (v.fiche.question) {
2953
+ throw new Arret(
2954
+ 'besoin',
2955
+ `Le relecteur attend une réponse : ${v.fiche.question}\n\nLa décision revient au régulateur. Le verdict :\n\n${v.texte}`
2956
+ );
2957
+ }
2958
+ if (v.verdict === 'ACCEPTÉ' && (!v.arbre || ctx.verifie === v.arbre)) {
2959
+ commenter(ctx, composant, 'relecture', 'ACCEPTÉ ; le lot va à l’intégration.');
2960
+ etape = INTEGRATION;
2961
+ } else if (v.verdict === 'ACCEPTÉ') {
2962
+ // The whole final pass has not played on the lot accepted: it plays once, then the
2963
+ // integration.
2964
+ commenter(
2965
+ ctx,
2966
+ composant,
2967
+ 'relecture',
2968
+ 'ACCEPTÉ ; la passe finale entière, qui n’a pas joué sur ce lot, puis l’intégration.'
2969
+ );
2970
+ index = dernier;
2971
+ etape = CONTROLES;
2972
+ tentative = 0;
2973
+ finale = true;
2974
+ } else {
2975
+ // The reviews since the last ACCEPTÉ: a lot the queue sent back starts a new count.
2976
+ const rendus = rendusDepuisAccepte(ctx);
2977
+ if (rendus >= RELECTURES) {
2978
+ throw new Arret(
2979
+ 'relecture-arret',
2980
+ `${rendus} relectures ont rendu le lot : le circuit s'arrête à la troisième. La décision revient au régulateur.\n\nLe dernier verdict :\n\n${v.texte}`
2981
+ );
2982
+ }
2983
+ const renvois = renvoisDuVerdict(ctx, v.fiche, v.texte);
2984
+ ctx.renvois = Object.fromEntries([...renvois].map(([i, r]) => [ctx.composants[i], r]));
2985
+ dernier = ctx.composants.length - 1;
2986
+ ({ index, etape, tentative, rapport, finale } = suivant(ctx, 0, dernier));
2987
+ const qui = [...renvois]
2988
+ .sort(([a], [b]) => a - b)
2989
+ .map(([i, r]) => `${r.etape} ${ctx.composants[i]}`)
2990
+ .join(', ');
2991
+ commenter(
2992
+ ctx,
2993
+ composant,
2994
+ 'relecture',
2995
+ `RENDU (relecture ${rendus}) ; le circuit relance ${qui}, puis la passe finale restreinte à ce que le correctif change et à ses dépendants, et le relecteur sur la différence.`
2996
+ );
2997
+ }
2998
+ } else if (etape === INTEGRATION) {
2999
+ const r = integrer(ctx);
3000
+ if (r.code === A_REPASSER) throw new Arret('a-repasser', fin(r.sortie, 40));
3001
+ if (r.code !== 0) {
3002
+ throw new Arret(
3003
+ 'integration',
3004
+ `Le script d'intégration refuse le lot (code ${r.code}) : la décision revient au régulateur.\n\n${fin(r.sortie, 60)}`
3005
+ );
3006
+ }
3007
+ const branche = `agent/${ctx.ticket}`;
3008
+ const commit = spawnSync('git', ['-C', ctx.racine, 'rev-parse', '--short', branche], {
3009
+ encoding: 'utf8',
3010
+ }).stdout.trim();
3011
+ commenter(
3012
+ ctx,
3013
+ composant,
3014
+ 'integration',
3015
+ `${commit ? `commit ${commit} sur ${branche}` : 'lot commité'} ; la branche est en file.`
3016
+ );
3017
+ etape = EN_FILE;
3018
+ } else if (etape === EN_FILE) {
3019
+ const f = file(ctx);
3020
+ if (f.etat === 'ferme') {
3021
+ ecrireEtat(ctx, { ...etat, etape: 'integre', code: 0 });
3022
+ commenter(ctx, composant, 'file', `${f.raison}.`);
3023
+ return { code: 0 };
3024
+ }
3025
+ if (f.etat === 'en-file') throw new Arret('a-repasser', f.raison);
3026
+ throw new Arret(
3027
+ 'integration',
3028
+ `La file rouvre le ticket : la décision revient au régulateur.\n\n${f.raison}`
3029
+ );
3030
+ } else {
3031
+ throw new Usage(`étape inconnue dans l'état : ${etape}`);
3032
+ }
3033
+ } catch (e) {
3034
+ if (!(e instanceof Arret)) throw e;
3035
+ const code = ARRETS[e.nom];
3036
+ // A stop of a developpeur's session rests on the reds it was launched on.
3037
+ const faits = etape === CODE ? { ...ctx.rougesDuDeveloppeur, ...e.faits } : e.faits;
3038
+ ecrireEtat(ctx, { ...etat, code, raison: e.nom, rapport: e.rapport });
3039
+ // The stop kept with the circuit's results, which the bilan counts.
3040
+ inscrire(ctx, {
3041
+ role: AUTEUR_DU_CIRCUIT,
3042
+ etape,
3043
+ composant,
3044
+ arret: e.nom,
3045
+ ...(etape === CONTROLES && (finale || index === dernier) ? { finale: true } : {}),
3046
+ ...(e.resultat ? { resultat: e.resultat } : {}),
3047
+ });
3048
+ const ou = ouDansLeCircuit({
3049
+ etape,
3050
+ finale: finale || (etape === CONTROLES && index === dernier),
3051
+ });
3052
+ commenter(
3053
+ ctx,
3054
+ composant,
3055
+ etape,
3056
+ `arrêt ${e.nom} à ${ou} — ${premiereLigne(e.rapport)} — journal ${ctx.journal}`
3057
+ );
3058
+ process.stderr.write(
3059
+ `⛔ Circuit de ${ctx.ticket} arrêté : ${e.nom}, à ${ou} de ${composant}.\n`
3060
+ );
3061
+ return {
3062
+ code,
3063
+ nom: e.nom,
3064
+ rapport: e.rapport,
3065
+ rouges: e.rouges,
3066
+ motif: e.motif,
3067
+ faits,
3068
+ etat,
3069
+ };
3070
+ } finally {
3071
+ if (menee) inscrireEtape(ctx, menee);
3072
+ }
3073
+ }
3074
+ }
3075
+
3076
+ // The decision of the regulator's sheet <fiche>: `{ ligne, reprendre: true, contexte }` or
3077
+ // The decision of the regulator's sheet <fiche>: `{ ligne, reprendre, contexte }` for a RELANCE
3078
+ // --reprendre, `contexte` its context or null; `{ ligne, depuis }` for a RELANCE --depuis,
3079
+ // `depuis` each component it plays again in the order of the title, `{ etape, composant,
3080
+ // contexte }`, as GitLab's « Retry failed jobs » retries every job in one go; `{ ligne, question }`
3081
+ // for a MONTE; `ligne` the decision as the circuit writes it at the ticket. Null for a `--depuis`
3082
+ // that names a component the title does not.
3083
+ function decisionDe(ctx, fiche) {
3084
+ if (fiche.decision === 'MONTE') {
3085
+ return { ligne: `MONTE : ${fiche.question}`, question: fiche.question };
3086
+ }
3087
+ if (fiche.decision === 'RELANCE --reprendre') {
3088
+ const contexte = fiche.contexte?.trim() || null;
3089
+ const ligne = `RELANCE --reprendre${contexte ? `\nCONTEXTE : ${contexte}` : ''}`;
3090
+ return { ligne, reprendre: true, contexte };
3091
+ }
3092
+ const indices = fiche.depuis.map(x => indexDe(ctx, x.composant));
3093
+ if (indices.some(i => i < 0)) return null;
3094
+ const depuis = fiche.depuis
3095
+ .map((x, n) => ({
3096
+ etape: x.role,
3097
+ composant: ctx.composants[indices[n]],
3098
+ contexte: x.contexte?.trim() || null,
3099
+ }))
3100
+ .sort((a, b) => ctx.composants.indexOf(a.composant) - ctx.composants.indexOf(b.composant));
3101
+ return {
3102
+ ligne: [
3103
+ `RELANCE --depuis ${depuis.map(x => `${x.etape} ${x.composant}`).join(', ')}`,
3104
+ ...depuis.filter(x => x.contexte).map(x => `CONTEXTE ${x.composant} : ${x.contexte}`),
3105
+ ].join('\n'),
3106
+ depuis,
3107
+ };
3108
+ }
3109
+
3110
+ // Whether the stops <a> and <b> are the same: their name, step and component.
3111
+ const memeArret = (a, b) =>
3112
+ Boolean(a && b) &&
3113
+ a.nom === b.nom &&
3114
+ a.etat.etape === b.etat.etape &&
3115
+ a.etat.composant === b.etat.composant;
3116
+
3117
+ // Whether the testwriter of the component at <i> has written its tests, as the state <etat> of a stop
3118
+ // says: a component before the one it stopped at, that one past its testwriter's step, every one at
3119
+ // the final pass, one the circuit has carried through (`atteints`), or one without tests.
3120
+ function testwriterPasse(ctx, etat, i) {
3121
+ const ici = ctx.composants.indexOf(etat.composant);
3122
+ return (
3123
+ etat.finale === true ||
3124
+ i < ici ||
3125
+ (i === ici && etat.etape !== TESTS) ||
3126
+ ctx.atteints.includes(ctx.composants[i]) ||
3127
+ sansTests(ctx, i)
3128
+ );
3129
+ }
3130
+
3131
+ // The fingerprint of the ticket's description, read now.
3132
+ const empreinteDuTicket = ctx =>
3133
+ createHash('sha256').update(leTicket(ctx).description).digest('hex');
3134
+
3135
+ // The fingerprint of what the stop <a> rests on, taken once: the copy (each file it changes
3136
+ // against its HEAD) and the ticket's description. The same stop come back on the same fingerprint
3137
+ // brings no new fact.
3138
+ function signatureDe(ctx, a) {
3139
+ a.signature ??= createHash('sha256')
3140
+ .update(existsSync(ctx.copie) ? empreinte(ctx, '.') : '')
3141
+ .update(empreinteDuTicket(ctx))
3142
+ .digest('hex');
3143
+ return a.signature;
3144
+ }
3145
+
3146
+ // How the circuit reads each kind of stop of the table (scripts/etapes.mjs) on the stop <a>; a
3147
+ // kind it reads elsewhere (a developpeur's sending, the title on an answer, the description on a
3148
+ // decision) has none here.
3149
+ const LECTURES = {
3150
+ 'meme-arret-sans-fait-nouveau': (ctx, a) => {
3151
+ const avant = ctx.relances.at(-1);
3152
+ return memeArret(avant, a) && avant.signature === signatureDe(ctx, a);
3153
+ },
3154
+ 'a-repasser': (ctx, a) => a.nom === 'a-repasser',
3155
+ 'a-arbitrer': (ctx, a) => a.faits?.genre === 'a-arbitrer',
3156
+ };
3157
+
3158
+ // The first line of the table of the stops that settles the stop <a>, in the table's order, or
3159
+ // null: the regulator decides.
3160
+ const ligneDeLArret = (ctx, a) => TABLE_DES_ARRETS.find(l => LECTURES[l.genre]?.(ctx, a)) ?? null;
3161
+
3162
+ // The decision the line <ligne> of the table, of gesture `testwriter`, gives for the component at
3163
+ // <i>, <contexte> its context: a RELANCE --depuis testwriter, as a regulator's (decisionDe), `ligne`
3164
+ // as the circuit writes it. The components after it that the circuit has carried through
3165
+ // (`atteints`) play nothing, the final pass judges them.
3166
+ function decisionDuGeste(ctx, ligne, i, contexte) {
3167
+ const composant = ctx.composants[i];
3168
+ const etape = ligne.geste === 'testwriter' ? TESTS : null;
3169
+ return {
3170
+ ligne: `RELANCE --depuis ${etape} ${composant}`,
3171
+ depuis: [{ etape, composant, contexte }],
3172
+ };
3173
+ }
3174
+
3175
+ // Why the fact <f> of a regulator's sheet is false on the stop <a> and the state, or null when it
3176
+ // is true.
3177
+ function faitFaux(ctx, a, f) {
3178
+ const i = f.composant ? indexDe(ctx, f.composant) : -1;
3179
+ const chemin = i >= 0 ? ctx.composants[i] : f.composant;
3180
+ const titre = ctx.composants.join(', ');
3181
+ const rouges = a.faits?.rouges ?? [];
3182
+ const verifier = {
3183
+ 'au-titre': () => [i >= 0, `le titre ne le nomme pas (${titre})`],
3184
+ 'hors-titre': () => [i < 0, `le titre le nomme (${titre})`],
3185
+ 'rouge-de': () => [
3186
+ rouges.includes(chemin),
3187
+ `les rouges de l'arrêt sont à ${rouges.join(', ') || 'aucun composant'}`,
3188
+ ],
3189
+ 'sans-changement': () => [
3190
+ i >= 0 && !livre(ctx, chemin),
3191
+ i >= 0 ? `la copie change ${chemin}` : `${f.composant} n'est pas un composant du titre`,
3192
+ ],
3193
+ 'sous-charge': () => [
3194
+ a.faits?.aRepasser === true,
3195
+ "aucune commande de l'arrêt n'est à repasser : son rouge ne dépend pas d'une durée sous charge",
3196
+ ],
3197
+ };
3198
+ const [vrai, pourquoi] = verifier[f.sujet]();
3199
+ return vrai
3200
+ ? null
3201
+ : `le fait « ${f.sujet}${f.composant ? ` ${f.composant}` : ''} » est faux : ${pourquoi}`;
3202
+ }
3203
+
3204
+ // Why the circuit refuses the decision <d> of the regulator's sheet <fiche> on the stop <a>,
3205
+ // checked on the state before it is applied (an admission control), or null when it admits it,
3206
+ // each component of a RELANCE --depuis judged: a component out of the title, or named twice, a
3207
+ // step the state does not hold, a developpeur before his testwriter, a criterion added and no
3208
+ // testwriter first (the table's `critere-neuf`), a fact of its sheet the state proves false, the
3209
+ // same RELANCE as the one before on the same stop with no new fact.
3210
+ function refusDe(ctx, a, fiche, d) {
3211
+ if (!d) {
3212
+ const hors = fiche.depuis.filter(x => indexDe(ctx, x.composant) < 0).map(x => x.composant);
3213
+ return `sa décision nomme ${hors.join(', ')}, qui n'${hors.length > 1 ? 'en sont' : 'est'} pas un composant du titre (${ctx.composants.join(', ')})`;
3214
+ }
3215
+ const deuxFois = (d.depuis ?? []).find(
3216
+ (x, n, l) => l.findIndex(y => y.composant === x.composant) < n
3217
+ );
3218
+ if (deuxFois) {
3219
+ return `sa décision nomme ${deuxFois.composant} deux fois : un composant se relance depuis une seule étape`;
3220
+ }
3221
+ const faux = (fiche.faits ?? []).map(f => faitFaux(ctx, a, f)).find(Boolean);
3222
+ if (faux) return faux;
3223
+ const plein = k => Boolean(String(fiche[k] ?? '').trim());
3224
+ if (d.question && (plein('arbitrage') || plein('critere'))) {
3225
+ return `une MONTE laisse la décision au superviseur : son arbitrage et son critère vont dans sa question`;
3226
+ }
3227
+ // The run starts at the first component of a --depuis, in the order of the title.
3228
+ if (plein('critere') && d.depuis?.[0].etape !== TESTS) {
3229
+ return `un critère ajouté part au testwriter avant le développeur (table des arrêts, ${gesteDe('critere-neuf').genre}), par RELANCE --depuis testwriter <composant>`;
3230
+ }
3231
+ if (d.question) return null;
3232
+ if (d.reprendre && !etapeDuCircuit(a.etat)) {
3233
+ return `RELANCE --reprendre reprend l'état à l'étape ${a.etat.etape}, qui n'est pas une étape du circuit : la décision nomme son agent (RELANCE --depuis)`;
3234
+ }
3235
+ const avantSesTests = (d.depuis ?? []).find(
3236
+ x => x.etape === CODE && !testwriterPasse(ctx, a.etat, ctx.composants.indexOf(x.composant))
3237
+ );
3238
+ if (avantSesTests) {
3239
+ const { composant } = avantSesTests;
3240
+ return `RELANCE --depuis developpeur ${composant} lance le développeur de ${composant} avant son testwriter, dont les tests ne sont pas écrits : un composant commence par son testwriter (RELANCE --depuis testwriter ${composant})`;
3241
+ }
3242
+ // The run starts at the first component of a --depuis, in the order of the title.
3243
+ const testwriter = d.depuis?.[0].etape === TESTS || (d.reprendre && a.etat.etape === TESTS);
3244
+ if (a.ticket && !testwriter && a.ticket !== empreinteDuTicket(ctx)) {
3245
+ return `la description du ticket a changé pendant l'arbitrage : un critère neuf part au testwriter avant le développeur (table des arrêts, ${gesteDe('critere-neuf').genre}), par RELANCE --depuis testwriter <composant>`;
3246
+ }
3247
+ const avant = ctx.relances.at(-1);
3248
+ const connu = f => (avant?.faits ?? []).some(g => JSON.stringify(g) === JSON.stringify(f));
3249
+ if (memeArret(avant, a) && avant.decision === d.ligne && (fiche.faits ?? []).every(connu)) {
3250
+ return `la même RELANCE que la précédente sur cet arrêt (${d.ligne.split('\n')[0]}), sans fait nouveau : elle a laissé l'arrêt tel qu'il était`;
3251
+ }
3252
+ return null;
3253
+ }
3254
+
3255
+ // The decision <d> on the stop <a> applied: kept among the RELANCE in a row on that stop, with the
3256
+ // facts <faits> of its sheet and the stop's fingerprint, then the run that starts again, from the
3257
+ // state <etat> of the stop for a RELANCE --reprendre.
3258
+ function appliquer(ctx, a, d, etat, faits = []) {
3259
+ const entree = { ...a, decision: d.ligne, faits, signature: signatureDe(ctx, a) };
3260
+ ctx.relances = memeArret(ctx.relances[0], a) ? [...ctx.relances, entree] : [entree];
3261
+ ctx.reprendre = Boolean(d.reprendre);
3262
+ ctx.depuis = d.depuis ?? null;
3263
+ ctx.contexte = d.contexte ?? null;
3264
+ return { depart: depart(ctx, { ...etat, renvois: ctx.renvois }) };
3265
+ }
3266
+
3267
+ // The stop <a> (`{ code, nom, rapport, faits, etat }`, `etat` the state of the step it stopped)
3268
+ // settled by a session of the regulator: a fresh one, or, with <reponse> (the supervisor's answer
3269
+ // to its question), its session `a.seance` resumed. A fresh session's decision is questioned first
3270
+ // (questionnerLaDecision): the regulator answers in its session, and the decision it gives at the
3271
+ // end is the one checked; a questioning that does not converge ends on « question », its
3272
+ // question for the supervisor. Its decision is checked on the state before it
3273
+ // is applied (refusDe); a decision refused asks the same session again once, the reason given, and
3274
+ // a second refusal ends the circuit on the stop. `{ code }` when the circuit ends: on « question »,
3275
+ // or on the stop itself for an answer in no form of decision, a decision refused twice or a session
3276
+ // in error; `{ depart }` for a RELANCE, where the run starts again.
3277
+ async function trancher(ctx, a, reponse = null) {
3278
+ const seance = reponse === null ? randomUUID() : a.seance;
3279
+ const garde = {
3280
+ seance,
3281
+ arret: a.nom,
3282
+ code: a.code,
3283
+ rapport: a.rapport,
3284
+ faits: a.faits ?? {},
3285
+ ticket: a.ticket ?? null,
3286
+ signature: a.signature ?? null,
3287
+ };
3288
+ const { composant } = a.etat;
3289
+ // The same stop come back after a RELANCE counts its pass at the arbitrage.
3290
+ const passe = memeArret(ctx.relances[0], a) ? ctx.relances.length + 1 : 1;
3291
+ const arret = {
3292
+ ...a.etat,
3293
+ code: a.code,
3294
+ raison: a.nom,
3295
+ rapport: a.rapport,
3296
+ boucles: { arret: passe },
3297
+ };
3298
+ ecrireEtat(ctx, { ...arret, regulateur: garde });
3299
+ const valeurs = {
3300
+ depot: ctx.racine,
3301
+ ticket: ctx.ticket,
3302
+ sujet: ctx.sujet,
3303
+ arret: a.nom,
3304
+ code: a.code,
3305
+ // The step by its number and title, « 2.3 Code ».
3306
+ etape: ouDansLeCircuit(a.etat),
3307
+ composant,
3308
+ raison: String(a.motif ?? a.rapport ?? '').trim() || 'aucune',
3309
+ // The reds of the stop, read in the structured results, each with its component.
3310
+ rouges: a.rouges?.length
3311
+ ? a.rouges
3312
+ .map(r =>
3313
+ [`- ${r.composant} : ${r.texte}`, ...(r.detail ? ['', r.detail, ''] : [])].join('\n')
3314
+ )
3315
+ .join('\n')
3316
+ : 'aucun',
3317
+ etat: ctx.etat,
3318
+ journal: ctx.journal,
3319
+ reponse,
3320
+ // The plan it judges on: the ticket's description, read now, and on the supervisor's answer
3321
+ // what the answer made of it (verserAuPlan).
3322
+ description: leTicket(ctx).description.trim() || 'aucune',
3323
+ plan: ctx.plan ?? 'inchangé',
3324
+ };
3325
+ const options = {
3326
+ horsEnveloppe: true,
3327
+ droits: horsDeLEnveloppe(ctx, REGULATEUR, 'sonnet'),
3328
+ id: seance,
3329
+ };
3330
+ // Its sheet, read as any session's: one that is absent or out of its schema resumes the session
3331
+ // once, asking for it; twice none, or a session that cannot end, leaves no decision.
3332
+ let d = null;
3333
+ let fiche = null;
3334
+ let pourquoi = null;
3335
+ const refus = [];
3336
+ try {
3337
+ fiche = await jusquALaFiche(
3338
+ ctx,
3339
+ REGULATEUR,
3340
+ composant,
3341
+ consigne(valeurs, reponse === null ? 2 : 3),
3342
+ { ...options, premiere: reponse === null ? 'ouvre' : 'reprend' }
3343
+ );
3344
+ // Its decision questioned before the admission control, save on the supervisor's answer.
3345
+ const q =
3346
+ reponse === null
3347
+ ? await questionnerLaDecision(ctx, a, fiche, {
3348
+ options,
3349
+ etat: { ...arret, regulateur: garde },
3350
+ valeurs,
3351
+ })
3352
+ : { fiche, question: null };
3353
+ fiche = q.fiche;
3354
+ // A questioning that does not converge goes up to the supervisor, as the regulator's question.
3355
+ if (q.question) d = { ligne: `MONTE : ${q.question}`, question: q.question };
3356
+ while (!d && !pourquoi) {
3357
+ d = decisionDe(ctx, fiche);
3358
+ const r = refusDe(ctx, a, fiche, d);
3359
+ if (!r) break;
3360
+ d = null;
3361
+ refus.push(r);
3362
+ commenter(ctx, composant, REGULATEUR, `décision refusée — ${r}`);
3363
+ if (refus.length > 1) {
3364
+ pourquoi = `sa décision, vérifiée sur l'état, est refusée deux fois : ${refus.join(' ; ')}`;
3365
+ break;
3366
+ }
3367
+ fiche = await jusquALaFiche(
3368
+ ctx,
3369
+ REGULATEUR,
3370
+ composant,
3371
+ consigne({ ticket: ctx.ticket, sujet: ctx.sujet, refus: r }, 6),
3372
+ { ...options, premiere: 'reprend' }
3373
+ );
3374
+ }
3375
+ } catch (e) {
3376
+ if (!(e instanceof Arret)) throw e;
3377
+ pourquoi = e.rapport;
3378
+ }
3379
+ if (!d) {
3380
+ ecrireEtat(ctx, {
3381
+ ...arret,
3382
+ rapport: `${String(a.rapport ?? '').trim()}\n\nLE RÉGULATEUR ne rend pas de décision : ${pourquoi}`,
3383
+ });
3384
+ process.stderr.write(
3385
+ `⛔ Circuit de ${ctx.ticket} arrêté : ${a.nom} ; le régulateur ne rend pas de décision.\n`
3386
+ );
3387
+ return { code: a.code };
3388
+ }
3389
+ commenter(ctx, composant, REGULATEUR, d.ligne);
3390
+ if (d.question) {
3391
+ ctx.relances = [];
3392
+ ecrireEtat(ctx, {
3393
+ ...arret,
3394
+ code: ARRETS.question,
3395
+ raison: 'question',
3396
+ rapport: d.question,
3397
+ regulateur: garde,
3398
+ });
3399
+ process.stderr.write(
3400
+ `⛔ Circuit de ${ctx.ticket} arrêté : question du régulateur au superviseur, qui y répond ou la porte au responsable ; la réponse se passe par \`npx grillhouse circuit ${ctx.ticket} --reponse "<texte>"\`.\n`
3401
+ );
3402
+ return { code: ARRETS.question };
3403
+ }
3404
+ ecrireLArbitrage(ctx, composant, fiche);
3405
+ return appliquer(ctx, a, d, arret, fiche.faits ?? []);
3406
+ }
3407
+
3408
+ // What the regulator's sheet <fiche> settled alone, written by the circuit: its `arbitrage` at the
3409
+ // ticket (`Circuit · <composant> · regulateur : arbitrage — <texte>`, which
3410
+ // scripts/consommateurs-arbitres.mjs reads), and its `critere` at the end of the description, the
3411
+ // criteria's place, numbered after its last numbered line.
3412
+ function ecrireLArbitrage(ctx, composant, fiche) {
3413
+ const arbitrage = String(fiche.arbitrage ?? '').trim();
3414
+ if (arbitrage) commenter(ctx, composant, REGULATEUR, `arbitrage — ${arbitrage}`);
3415
+ const critere = String(fiche.critere ?? '').trim();
3416
+ if (!critere) return;
3417
+ const description = leTicket(ctx).description.trimEnd();
3418
+ const n = Number([...description.matchAll(/^(\d+)\.\s/gm)].at(-1)?.[1] ?? 0) + 1;
3419
+ const r = ctx.bd('update', ctx.ticket, '-d', `${description}\n${n}. ${critere}\n`);
3420
+ if (r.status !== 0) {
3421
+ commenter(ctx, composant, REGULATEUR, `critère ${n} non écrit : ${r.stderr}`);
3422
+ }
3423
+ }
3424
+
3425
+ // The stop <a> ended for the supervisor by the line <ligne> of the table: the same stop come back
3426
+ // with no new fact. Its exit code.
3427
+ function rendreAuSuperviseur(ctx, a, ligne) {
3428
+ const decisions = ctx.relances.map((x, i) => `${i + 1}. ${x.decision}`).join('\n\n');
3429
+ ecrireEtat(ctx, {
3430
+ ...a.etat,
3431
+ code: a.code,
3432
+ raison: a.nom,
3433
+ rapport: `${String(a.rapport ?? '').trim()}\n\nLE MÊME ARRÊT revient sans fait nouveau, la copie et la description du ticket telles qu'à la décision d'avant : le circuit s'arrête, pour le superviseur (table des arrêts, ${ligne.genre}). Les décisions sur cet arrêt :\n\n${decisions}`,
3434
+ });
3435
+ commenter(
3436
+ ctx,
3437
+ a.etat.composant,
3438
+ 'arbitrage',
3439
+ `${ligne.genre} → le superviseur : l'arrêt ${a.nom} revient sans fait nouveau.`
3440
+ );
3441
+ return a.code;
3442
+ }
3443
+
3444
+ // The run from <suite> on: each stop other than 0 read in the table of the stops (scripts/
3445
+ // etapes.mjs), whose gesture the circuit applies itself, or settled by the regulator, whose
3446
+ // RELANCE runs the circuit again, until the circuit ends; its exit code. The stop that follows the
3447
+ // third RELANCE in a row on it ends the circuit without a regulator.
3448
+ async function conduire(ctx, suite) {
3449
+ for (;;) {
3450
+ if (suite.code !== undefined) return suite.code;
3451
+ const a = await derouler(ctx, suite.depart);
3452
+ if (a.code === 0) return a.code;
3453
+ // The description and the copy as the stop finds them: a decision is checked against the
3454
+ // first, and the stop that comes back against both.
3455
+ a.ticket = empreinteDuTicket(ctx);
3456
+ signatureDe(ctx, a);
3457
+ const ligne = ligneDeLArret(ctx, a);
3458
+ if (ligne?.geste === 'a-repasser') return a.code;
3459
+ if (ligne?.geste === 'superviseur') return rendreAuSuperviseur(ctx, a, ligne);
3460
+ if (ctx.relances.length >= RELANCES_DU_REGULATEUR && memeArret(ctx.relances[0], a)) {
3461
+ const decisions = ctx.relances.map((x, i) => `${i + 1}. ${x.decision}`).join('\n\n');
3462
+ ecrireEtat(ctx, {
3463
+ ...a.etat,
3464
+ code: a.code,
3465
+ raison: a.nom,
3466
+ rapport: `${String(a.rapport ?? '').trim()}\n\nLE RÉGULATEUR a rendu ${ctx.relances.length} décisions RELANCE d'affilée sur cet arrêt : le circuit s'arrête au lieu d'une ${ctx.relances.length + 1}e relance. Ses décisions :\n\n${decisions}`,
3467
+ });
3468
+ commenter(
3469
+ ctx,
3470
+ a.etat.composant,
3471
+ REGULATEUR,
3472
+ `${ctx.relances.length} RELANCE d'affilée sur l'arrêt ${a.nom} : le circuit s'arrête.`
3473
+ );
3474
+ return a.code;
3475
+ }
3476
+ suite = await trancher(ctx, a);
3477
+ }
3478
+ }
3479
+
3480
+ // Where the run starts: the first component's testwriter, the first component of a --depuis in the
3481
+ // order of the title, at its step, or the step and component of the state <e> (null without one).
3482
+ // With <enCours>, the index of the component whose run sends it there, the renvois of that component
3483
+ // and of the components after it stay pending, as a queue of jobs grows and is never replaced: each
3484
+ // plays after it, its findings kept.
3485
+ function depart(ctx, e, enCours = null) {
3486
+ const fin = ctx.composants.length - 1;
3487
+ if (ctx.depuis) {
3488
+ const attente = Object.fromEntries(
3489
+ Object.entries(enCours === null ? {} : (ctx.renvois ?? {})).filter(
3490
+ ([c]) => ctx.composants.indexOf(c) >= enCours
3491
+ )
3492
+ );
3493
+ ctx.renvois = {
3494
+ ...attente,
3495
+ ...Object.fromEntries(
3496
+ ctx.depuis.map(({ etape, composant, contexte }) => [
3497
+ composant,
3498
+ {
3499
+ developpeur: null,
3500
+ ...attente[composant],
3501
+ etape,
3502
+ testwriter: null,
3503
+ contexte: contexte ?? null,
3504
+ },
3505
+ ])
3506
+ ),
3507
+ };
3508
+ const [{ etape, composant }] = [...ctx.depuis].sort(
3509
+ (a, b) => ctx.composants.indexOf(a.composant) - ctx.composants.indexOf(b.composant)
3510
+ );
3511
+ return {
3512
+ index: ctx.composants.indexOf(composant),
3513
+ etape,
3514
+ tentative: 1,
3515
+ rapport: null,
3516
+ dernier: fin,
3517
+ finale: false,
3518
+ };
3519
+ }
3520
+ if (!ctx.reprendre) {
3521
+ // The run starts over: no component is reached yet, no lot verified.
3522
+ ctx.atteints = [];
3523
+ ctx.verifie = null;
3524
+ return { index: 0, etape: TESTS, tentative: 1, rapport: null, dernier: fin, finale: false };
3525
+ }
3526
+ if (!e) throw new Usage(`rien à reprendre : ${ctx.etat} n'existe pas.`);
3527
+ if (e.etape === 'integre')
3528
+ throw new Usage(`rien à reprendre : le lot de ${ctx.ticket} est intégré.`);
3529
+ const index = ctx.composants.indexOf(e.composant);
3530
+ if (index < 0)
3531
+ throw new Usage(`le composant de l'état, ${e.composant}, n'est plus dans le titre.`);
3532
+ const dernier = ctx.composants.indexOf(e.dernier);
3533
+ ctx.renvois = e.renvois ?? null;
3534
+ return {
3535
+ index,
3536
+ etape: e.etape,
3537
+ tentative: e.etape === CONTROLES ? 0 : 1,
3538
+ rapport: e.rapport,
3539
+ dernier: dernier < 0 ? fin : dernier,
3540
+ finale: e.finale === true,
3541
+ };
3542
+ }
3543
+
3544
+ // The end of the run, its exit code <code>: an end on a stop kept with the circuit's results
3545
+ // (`montee`: its code and its stop), from which the bilan measures the supervisor's wait to the run
3546
+ // that answers it, then the ticket's bilan at the ticket (scripts/bilan.mjs); a bilan it cannot
3547
+ // write leaves the code as it is.
3548
+ function aLaFin(ctx, code) {
3549
+ try {
3550
+ if (code !== 0) {
3551
+ inscrire(ctx, {
3552
+ role: AUTEUR_DU_CIRCUIT,
3553
+ composant: ctx.courant?.composant ?? ctx.composants.at(-1),
3554
+ montee: { code, raison: ctx.courant?.raison ?? null },
3555
+ });
3556
+ }
3557
+ const texte = ecrireLeBilan(ctx.racine, ctx.ticket);
3558
+ commenter(ctx, ctx.courant?.composant ?? ctx.composants.at(-1), 'bilan', texte);
3559
+ } catch (e) {
3560
+ process.stderr.write(`⚠️ bilan non écrit au ticket : ${e.message}\n`);
3561
+ }
3562
+ return code;
3563
+ }
3564
+
3565
+ // The questions <questions> of a questionneur's sheet, one per line, each with what it aims at.
3566
+ const lignesDesQuestions = questions =>
3567
+ (questions ?? []).map((q, i) => `${i + 1}. (${q.vise}) ${q.question}`).join('\n');
3568
+
3569
+ // The answers <reponses> of an author's sheet, one per line: CHANGE and what the proposal
3570
+ // becomes, or GARDE and why.
3571
+ const lignesDesReponses = reponses =>
3572
+ (reponses ?? []).length
3573
+ ? reponses.map((r, i) => `${i + 1}. ${r.choix} — ${r.question} — ${r.texte}`).join('\n')
3574
+ : 'aucune réponse dans sa fiche';
3575
+
3576
+ // The rubric « ## <titre> » of the project's charter (CLAUDE.md of the main tree), up to the next
3577
+ // `##` heading, or what stands in its place.
3578
+ function rubrique(ctx, titre) {
3579
+ const lignes = lire(path.join(ctx.racine, 'CLAUDE.md')).split('\n');
3580
+ const i = lignes.findIndex(l => l.trim() === `## ${titre}`);
3581
+ if (i < 0) return 'absente de la charte du projet (CLAUDE.md)';
3582
+ const fin = lignes.findIndex((l, j) => j > i && l.startsWith('## '));
3583
+ return (
3584
+ lignes
3585
+ .slice(i + 1, fin < 0 ? undefined : fin)
3586
+ .join('\n')
3587
+ .trim() || 'vide'
3588
+ );
3589
+ }
3590
+
3591
+ // The meta-frame, the frame's plugin's, read at the plugin's root (scripts/cadre.mjs).
3592
+ const METACADRE = path.join(GREFFON, 'METACADRE.md');
3593
+
3594
+ // The questioning of a proposal by the questionneur, in rounds with its author, never side by
3595
+ // side: the questionneur asks (its sheet, `questions`), the author answers by resuming its own
3596
+ // session (<q>.repondre: its answers, whether its proposal changed and what it becomes), the
3597
+ // questionneur reads the answer by resuming its own, TOURS rounds at most. <q>: `{ objet, nom,
3598
+ // composant, etat, auteur, proposition, description, releve, empreinte, repondre }`, <objet> plan
3599
+ // or decision, <nom> the proposal in words, <etat> the state the questioning runs in, which it
3600
+ // names (`questionnement`, its round in `boucles`) while it runs and leaves as it was at its end.
3601
+ // Each question and each answer is written at the ticket, and the end is kept with the circuit's
3602
+ // results (`questionnement`, which the bilan counts, with what <empreinte> gives). `{ issue,
3603
+ // tours, change, reste }`: AUCUNE, a sheet without question; DÉSACCORD, the questionneur's; MONTE,
3604
+ // the last round still questioned; <reste> what remains for the supervisor. Throws the Arret of a
3605
+ // session that cannot end.
3606
+ async function questionner(ctx, q) {
3607
+ const seance = randomUUID();
3608
+ const options = {
3609
+ horsEnveloppe: true,
3610
+ droits: horsDeLEnveloppe(ctx, QUESTIONNEUR, 'sonnet'),
3611
+ id: seance,
3612
+ };
3613
+ const dire = texte => commenter(ctx, q.composant, 'questionnement', texte);
3614
+ let change = false;
3615
+ let reponses = null;
3616
+ let proposition = q.proposition;
3617
+ const fin = (issue, tours, reste = null) => {
3618
+ ecrireEtat(ctx, q.etat);
3619
+ inscrire(ctx, {
3620
+ role: AUTEUR_DU_CIRCUIT,
3621
+ composant: q.composant,
3622
+ questionnement: { objet: q.objet, tours, change, issue, ...(q.empreinte?.() ?? {}) },
3623
+ });
3624
+ return { issue, tours, change, reste };
3625
+ };
3626
+ for (let tour = 1; ; tour++) {
3627
+ ecrireEtat(ctx, {
3628
+ ...q.etat,
3629
+ questionnement: { objet: q.objet, tour, seance },
3630
+ boucles: { ...q.etat.boucles, questionnement: tour },
3631
+ });
3632
+ const ou = `${q.nom}, tour ${tour}/${TOURS}`;
3633
+ const texte =
3634
+ tour === 1
3635
+ ? consigne(
3636
+ {
3637
+ depot: ctx.racine,
3638
+ ticket: ctx.ticket,
3639
+ sujet: ctx.sujet,
3640
+ objet: q.nom,
3641
+ auteur: enMots(q.auteur),
3642
+ proposition,
3643
+ max: TOURS,
3644
+ metacadre: lire(METACADRE).trim(),
3645
+ arbitrer: rubrique(ctx, 'Comment on arbitre'),
3646
+ monte: rubrique(ctx, 'Ce qui monte au superviseur'),
3647
+ description: q.description.trim() || 'aucune',
3648
+ releve: q.releve || 'aucun',
3649
+ },
3650
+ 7
3651
+ )
3652
+ : consigne(
3653
+ {
3654
+ ticket: ctx.ticket,
3655
+ sujet: ctx.sujet,
3656
+ tour,
3657
+ max: TOURS,
3658
+ auteur: enMots(q.auteur),
3659
+ reponses,
3660
+ proposition,
3661
+ },
3662
+ 8
3663
+ );
3664
+ const fiche = await jusquALaFiche(ctx, QUESTIONNEUR, q.composant, texte, {
3665
+ ...options,
3666
+ premiere: tour === 1 ? 'ouvre' : 'reprend',
3667
+ });
3668
+ if (fiche.issue === 'AUCUNE') {
3669
+ dire(
3670
+ `${ou} — aucune question au tour ${tour}/${TOURS} ; ${change ? 'la proposition a changé' : 'la proposition reste telle quelle'}.`
3671
+ );
3672
+ return fin('AUCUNE', tour);
3673
+ }
3674
+ if (fiche.issue === 'DÉSACCORD') {
3675
+ dire(`${ou} — désaccord du questionneur : ${fiche.desaccord}`);
3676
+ return fin('DÉSACCORD', tour, `Le désaccord du questionneur : ${fiche.desaccord}`);
3677
+ }
3678
+ const questions = lignesDesQuestions(fiche.questions);
3679
+ dire(`${ou} — les questions du questionneur :\n${questions}`);
3680
+ if (tour >= TOURS) {
3681
+ return fin(
3682
+ 'MONTE',
3683
+ tour,
3684
+ `Les questions du questionneur au tour ${tour}/${TOURS}, sans réponse :\n${questions}`
3685
+ );
3686
+ }
3687
+ const r = await q.repondre(fiche.questions, tour);
3688
+ reponses = lignesDesReponses(r.reponses);
3689
+ dire(`${ou} — les réponses du ${enMots(q.auteur)} :\n${reponses}`);
3690
+ change ||= r.change;
3691
+ proposition = r.change ? r.proposition : 'inchangée depuis le tour d’avant';
3692
+ }
3693
+ }
3694
+
3695
+ // The fingerprint of a plan, its title and its description.
3696
+ const empreinteDuPlan = ({ titre, description }) =>
3697
+ createHash('sha256').update(`${titre}\n${description}`).digest('hex');
3698
+
3699
+ // Whether the plan of fingerprint <empreinte> has passed its questioning: a questioning that
3700
+ // ended without question on it, or the supervisor's answer to its stop.
3701
+ const planPasse = (ctx, empreinte) =>
3702
+ lesFiches(ctx).some(
3703
+ e =>
3704
+ e.role === AUTEUR_DU_CIRCUIT &&
3705
+ ((e.questionnement?.objet === 'plan' &&
3706
+ e.questionnement.issue === 'AUCUNE' &&
3707
+ e.questionnement.empreinte === empreinte) ||
3708
+ e.plan?.empreinte === empreinte)
3709
+ );
3710
+
3711
+ // The plan as the technical referent's sheet <fiche> changes it, written to the ticket by the
3712
+ // circuit (`bd update --title`, `-d`), never by the session: the changes made, in words. A title
3713
+ // whose components the repository does not hold stays as it was, said at the ticket.
3714
+ function changerLePlan(ctx, fiche, composant) {
3715
+ const avant = leTicket(ctx);
3716
+ const faits = [];
3717
+ const ecrire = (option, valeur, quoi) => {
3718
+ const r = ctx.bd('update', ctx.ticket, option, valeur);
3719
+ if (r.status === 0) faits.push(quoi);
3720
+ else commenter(ctx, composant, 'questionnement', `${quoi} non écrit : ${r.stderr}`);
3721
+ return r.status === 0;
3722
+ };
3723
+ const titre = fiche.titre?.trim();
3724
+ if (titre && titre !== avant.titre.trim()) {
3725
+ try {
3726
+ const lu = ctx.duTitre(titre);
3727
+ if (ecrire('--title', titre, `le titre : « ${titre} »`)) Object.assign(ctx, lu);
3728
+ } catch (e) {
3729
+ if (!(e instanceof Usage)) throw e;
3730
+ commenter(
3731
+ ctx,
3732
+ composant,
3733
+ 'questionnement',
3734
+ `titre neuf refusé, le titre reste : ${e.message}`
3735
+ );
3736
+ }
3737
+ }
3738
+ const description = fiche.description?.trim();
3739
+ if (description && description !== avant.description.trim()) {
3740
+ ecrire('-d', fiche.description, 'la description');
3741
+ }
3742
+ return faits;
3743
+ }
3744
+
3745
+ // The plan of the ticket questioned before the testwriter of the first component, after the survey
3746
+ // of its components (<releve>), or once an answer or a decision changed it (verserAuPlan): the
3747
+ // technical referent answers for it, a fresh session that defends the plan with the index and the
3748
+ // mature references, resumed from its second answer, or, with <auteur>, the referent's session
3749
+ // that changed it, resumed from its first; its changes are written to the ticket by the circuit.
3750
+ // <etat> the state the questioning runs in, the first testwriter's by default. A plan that has
3751
+ // passed its questioning, its title and description unchanged since, passes. Null when the run
3752
+ // goes on; else the exit code of the stop it ends on: « questionnement », for the supervisor, or
3753
+ // « composants-manquants » when the plan changed and the survey of its new text finds a component
3754
+ // its title misses.
3755
+ async function questionnerLePlan(ctx, releve, { etat: dans = null, auteur = null } = {}) {
3756
+ const t = leTicket(ctx);
3757
+ if (planPasse(ctx, empreinteDuPlan(t))) return null;
3758
+ const composant = dans?.composant ?? ctx.composants[0];
3759
+ const etat = dans ?? {
3760
+ etape: TESTS,
3761
+ composant,
3762
+ tentative: 1,
3763
+ code: null,
3764
+ raison: null,
3765
+ rapport: null,
3766
+ dernier: ctx.composants.at(-1),
3767
+ };
3768
+ const auteurDuPlan = auteur ?? randomUUID();
3769
+ let r;
3770
+ try {
3771
+ r = await questionner(ctx, {
3772
+ objet: 'plan',
3773
+ nom: 'le plan',
3774
+ composant,
3775
+ etat,
3776
+ auteur: REFERENT,
3777
+ proposition: `le plan du ticket : son titre, « ${t.titre} », sa description (LE TICKET, ci-dessous) et les composants qu'elle touche (LE RELEVÉ DES COMPOSANTS).`,
3778
+ description: t.description,
3779
+ releve,
3780
+ empreinte: () => ({ empreinte: empreinteDuPlan(leTicket(ctx)) }),
3781
+ repondre: async (questions, tour) => {
3782
+ const maintenant = leTicket(ctx);
3783
+ const ouvre = tour === 1 && !auteur;
3784
+ const texte = ouvre
3785
+ ? consigne(
3786
+ {
3787
+ depot: ctx.racine,
3788
+ ticket: ctx.ticket,
3789
+ sujet: ctx.sujet,
3790
+ titre: maintenant.titre,
3791
+ description: maintenant.description.trim() || 'aucune',
3792
+ releve: releve || 'aucun',
3793
+ tour,
3794
+ max: TOURS,
3795
+ questions: lignesDesQuestions(questions),
3796
+ },
3797
+ 10
3798
+ )
3799
+ : consigne(
3800
+ {
3801
+ ticket: ctx.ticket,
3802
+ sujet: ctx.sujet,
3803
+ tour,
3804
+ max: TOURS,
3805
+ questions: lignesDesQuestions(questions),
3806
+ forme:
3807
+ 'chaque réponse dans `reponses` ; quand une réponse CHANGE le plan, le titre ou la description qu’il devient, entiers, dans `titre` ou `description`',
3808
+ },
3809
+ 9
3810
+ );
3811
+ const f = await jusquALaFiche(ctx, REFERENT, composant, texte, {
3812
+ horsEnveloppe: true,
3813
+ droits: horsDeLEnveloppe(ctx, REFERENT, 'sonnet'),
3814
+ id: auteurDuPlan,
3815
+ premiere: ouvre ? 'ouvre' : 'reprend',
3816
+ });
3817
+ const faits = changerLePlan(ctx, f, composant);
3818
+ const neuf = leTicket(ctx);
3819
+ return {
3820
+ reponses: f.reponses,
3821
+ change: faits.length > 0,
3822
+ proposition: `le plan change (${faits.join(', ')}) ; son titre : « ${neuf.titre} » ; sa description :\n\n${neuf.description.trim()}`,
3823
+ };
3824
+ },
3825
+ });
3826
+ } catch (x) {
3827
+ // A session of the questioning that cannot end stops the circuit on its stop, before any
3828
+ // testwriter.
3829
+ if (!(x instanceof Arret)) throw x;
3830
+ ecrireEtat(ctx, { ...etat, code: ARRETS[x.nom], raison: x.nom, rapport: x.rapport });
3831
+ const ou = ouDansLeCircuit({ ...etat, questionnement: 'plan' });
3832
+ commenter(
3833
+ ctx,
3834
+ composant,
3835
+ 'questionnement',
3836
+ `arrêt ${x.nom} à ${ou} — ${premiereLigne(x.rapport)}`
3837
+ );
3838
+ process.stderr.write(`⛔ Circuit de ${ctx.ticket} arrêté : ${x.nom}, à ${ou}.\n`);
3839
+ return ARRETS[x.nom];
3840
+ }
3841
+ if (r.issue === 'AUCUNE') {
3842
+ // A plan changed: its new text surveyed again, before any session.
3843
+ return r.change ? composantsManquants(ctx) : null;
3844
+ }
3845
+ const code = ARRETS.questionnement;
3846
+ const rapport = [
3847
+ `Le questionnement du plan ${r.issue === 'DÉSACCORD' ? 'finit sur un désaccord' : `reste questionné au tour ${TOURS}/${TOURS}`} : la décision revient au superviseur.`,
3848
+ '',
3849
+ r.reste,
3850
+ '',
3851
+ `L'échange entier est au ticket (\`Circuit · … · questionnement\`). Le superviseur change le plan (\`bd update ${ctx.ticket} …\`) ou le garde, puis répond : \`npx grillhouse circuit ${ctx.ticket} --reponse "<texte>"\`, qui fait passer le plan.`,
3852
+ ].join('\n');
3853
+ ecrireEtat(ctx, {
3854
+ ...etat,
3855
+ code,
3856
+ raison: 'questionnement',
3857
+ rapport,
3858
+ boucles: { questionnement: r.tours },
3859
+ });
3860
+ inscrire(ctx, { role: AUTEUR_DU_CIRCUIT, etape: TESTS, composant, arret: 'questionnement' });
3861
+ const ou = ouDansLeCircuit({ ...etat, raison: 'questionnement' });
3862
+ commenter(
3863
+ ctx,
3864
+ composant,
3865
+ 'questionnement',
3866
+ `arrêt questionnement à ${ou} — ${premiereLigne(rapport)}`
3867
+ );
3868
+ process.stderr.write(
3869
+ `⛔ Circuit de ${ctx.ticket} arrêté : questionnement, à ${ou}.\n${rapport}\n`
3870
+ );
3871
+ return code;
3872
+ }
3873
+
3874
+ // The decision of the regulator's sheet <fiche> as the circuit writes it, or its own words when it
3875
+ // names a component out of the title.
3876
+ const ligneDeLaDecision = (ctx, fiche) =>
3877
+ decisionDe(ctx, fiche)?.ligne ??
3878
+ `${fiche.decision} ${(fiche.depuis ?? []).map(x => `${x.role} ${x.composant}`).join(', ')}`.trim();
3879
+
3880
+ // The decision of the regulator's sheet <fiche> on the stop <a>, as the questionneur reads it: the
3881
+ // decision, its facts, then the stop it settles (<valeurs>: its step, component, reason and reds,
3882
+ // as the regulator received them).
3883
+ const laDecision = (ctx, a, fiche, valeurs) =>
3884
+ [
3885
+ `la décision du régulateur sur l'arrêt ${a.nom} (code ${a.code}), à l'étape ${valeurs.etape} du composant ${valeurs.composant} :`,
3886
+ '',
3887
+ ligneDeLaDecision(ctx, fiche),
3888
+ ...((fiche.faits ?? []).length
3889
+ ? [
3890
+ '',
3891
+ `Ses faits : ${fiche.faits.map(f => `${f.sujet}${f.composant ? ` ${f.composant}` : ''}`).join(', ')}.`,
3892
+ ]
3893
+ : []),
3894
+ '',
3895
+ `LA RAISON DE L'ARRÊT, écrite par le circuit :\n\n${valeurs.raison}`,
3896
+ '',
3897
+ `LES ROUGES DE L'ARRÊT :\n\n${valeurs.rouges}`,
3898
+ ].join('\n');
3899
+
3900
+ // The decision of the regulator's sheet <premiere> on the stop <a> questioned before the admission
3901
+ // control: the regulator answers in its own session, resumed (<options>), and may change its
3902
+ // decision; <etat> the state of the arbitrage, <valeurs> the regulator's instruction. `{ fiche,
3903
+ // question }`: the sheet of its last answer, and the question for the supervisor when the
3904
+ // questioning ends on a disagreement or on its last round still questioned, else null.
3905
+ async function questionnerLaDecision(ctx, a, premiere, { options, etat, valeurs }) {
3906
+ let fiche = premiere;
3907
+ const r = await questionner(ctx, {
3908
+ objet: 'decision',
3909
+ nom: 'la décision du régulateur',
3910
+ composant: valeurs.composant,
3911
+ etat,
3912
+ auteur: REGULATEUR,
3913
+ proposition: laDecision(ctx, a, fiche, valeurs),
3914
+ description: leTicket(ctx).description,
3915
+ releve: null,
3916
+ repondre: async (questions, tour) => {
3917
+ const avant = ligneDeLaDecision(ctx, fiche);
3918
+ fiche = await jusquALaFiche(
3919
+ ctx,
3920
+ REGULATEUR,
3921
+ valeurs.composant,
3922
+ consigne(
3923
+ {
3924
+ ticket: ctx.ticket,
3925
+ sujet: ctx.sujet,
3926
+ tour,
3927
+ max: TOURS,
3928
+ questions: lignesDesQuestions(questions),
3929
+ forme:
3930
+ 'chaque réponse dans `reponses`, et ta décision, changée ou gardée, sous l’une des formes de ta consigne, ses `faits` vrais sur l’état',
3931
+ },
3932
+ 9
3933
+ ),
3934
+ { ...options, premiere: 'reprend' }
3935
+ );
3936
+ const apres = ligneDeLaDecision(ctx, fiche);
3937
+ return {
3938
+ reponses: fiche.reponses,
3939
+ change: apres !== avant,
3940
+ proposition: laDecision(ctx, a, fiche, valeurs),
3941
+ };
3942
+ },
3943
+ });
3944
+ if (r.issue === 'AUCUNE') return { fiche, question: null };
3945
+ return {
3946
+ fiche,
3947
+ question: `Le questionnement de la décision du régulateur ${r.issue === 'DÉSACCORD' ? 'finit sur un désaccord' : `reste questionné au tour ${TOURS}/${TOURS}`} : la décision revient au superviseur.\n\nSa décision : ${ligneDeLaDecision(ctx, fiche)}\n\n${r.reste}\n\nL'échange entier est au ticket (\`Circuit · … · questionnement\`).`,
3948
+ };
3949
+ }
3950
+
3951
+ // The components the survey of the ticket finds and its title does not name
3952
+ // (scripts/composants-affectes.mjs), before the testwriter of the first component: none, null; else
3953
+ // the stop « composants-manquants », its code, before any session and without a regulator. The
3954
+ // survey's text is kept for the questioning of the plan (`ctx.releve`).
3955
+ function composantsManquants(ctx) {
3956
+ const { rapport, releve } = verifierLeTitre({
3957
+ racine: existsSync(ctx.copie) ? ctx.copie : ctx.racine,
3958
+ ticket: ctx.ticket,
3959
+ description: leTicket(ctx).description,
3960
+ titre: ctx.composants,
3961
+ fichier: `${ctx.copie}.composants.json`,
3962
+ });
3963
+ ctx.releve = releve;
3964
+ if (!rapport) return null;
3965
+ const [composant] = ctx.composants;
3966
+ const code = ARRETS['composants-manquants'];
3967
+ ecrireEtat(ctx, {
3968
+ etape: TESTS,
3969
+ composant,
3970
+ tentative: 1,
3971
+ code,
3972
+ raison: 'composants-manquants',
3973
+ rapport,
3974
+ dernier: ctx.composants.at(-1),
3975
+ });
3976
+ const ou = ouDansLeCircuit({ etape: TESTS, raison: 'composants-manquants' });
3977
+ commenter(
3978
+ ctx,
3979
+ composant,
3980
+ 'composants',
3981
+ `arrêt composants-manquants à ${ou} — ${premiereLigne(rapport)}`
3982
+ );
3983
+ process.stderr.write(
3984
+ `⛔ Circuit de ${ctx.ticket} arrêté : composants-manquants, à ${ou}.\n${rapport}\n`
3985
+ );
3986
+ return code;
3987
+ }
3988
+
3989
+ // What pours into the plan (verserAuPlan), by its kind: the supervisor's answer to the
3990
+ // regulator's question (`--reponse`), or a decision of the owner given in session, out of any stop
3991
+ // « question » (`--decision`). Each one's words in the technical referent's instruction (the
3992
+ // twelfth template: `objet`, `quoi`, `suite`), at the ticket (`nom`, `garde`) and on a session of
3993
+ // the referent that cannot end (`reste`).
3994
+ const VERSEMENTS = {
3995
+ reponse: {
3996
+ objet: 'la réponse du superviseur à la question du régulateur',
3997
+ quoi: "LA RÉPONSE DU SUPERVISEUR, mot pour mot (la sienne, ou celle du responsable qu'il porte)",
3998
+ suite: "puis le régulateur tranche l'arrêt sur le plan",
3999
+ nom: 'la réponse du superviseur',
4000
+ garde: 'le régulateur reprend sans questionnement',
4001
+ reste: "l'arrêt « question » reste, pour une autre réponse",
4002
+ },
4003
+ decision: {
4004
+ objet: 'une décision du responsable donnée en séance, hors de toute question du circuit',
4005
+ quoi: 'LA DÉCISION DU RESPONSABLE, mot pour mot (que le superviseur porte)',
4006
+ suite:
4007
+ 'puis le circuit reprend là où il en était, et rejoue les seuls composants que le plan changé touche',
4008
+ nom: 'la décision du responsable',
4009
+ garde: 'le circuit reprend tel quel',
4010
+ reste: 'le circuit reste arrêté là où il en était, pour une autre décision',
4011
+ },
4012
+ };
4013
+
4014
+ // The text <texte> of the kind <genre> (VERSEMENTS), on the question <question>, poured into the
4015
+ // plan, as an RFC amended or an ADR: the plan is what every agent reads, the exchange at the
4016
+ // ticket its history. A fresh session of the technical referent, out of the envelope (the twelfth
4017
+ // template: the question, the text, the plan), changes the plan (a criterion added, removed or
4018
+ // made precise, its title) or keeps it, and the circuit writes the plan its sheet gives
4019
+ // (changerLePlan). A plan changed is surveyed again, then questioned (questionnerLePlan, the
4020
+ // referent answering in its own session, in the state <e>, the first testwriter's without one);
4021
+ // a plan the text leaves as it was is said at the ticket, with no questioning. `{ plan, avant }`,
4022
+ // the plan in words for the regulator's resumed session and the ticket before the text; `{ code }`
4023
+ // for the stop the run ends on: one of the survey or of the questioning, or the stop of a session
4024
+ // of the referent that cannot end, the state left as it was.
4025
+ async function verserAuPlan(ctx, e, { genre, texte, question }) {
4026
+ const v = VERSEMENTS[genre];
4027
+ const composant = e?.composant ?? ctx.composants[0];
4028
+ const avant = leTicket(ctx);
4029
+ const seance = randomUUID();
4030
+ const dire = t => commenter(ctx, composant, 'questionnement', t);
4031
+ let f;
4032
+ try {
4033
+ f = await jusquALaFiche(
4034
+ ctx,
4035
+ REFERENT,
4036
+ composant,
4037
+ consigne(
4038
+ {
4039
+ depot: ctx.racine,
4040
+ ticket: ctx.ticket,
4041
+ sujet: ctx.sujet,
4042
+ objet: v.objet,
4043
+ titre: avant.titre,
4044
+ description: avant.description.trim() || 'aucune',
4045
+ question: String(question ?? '').trim() || 'aucune',
4046
+ quoi: v.quoi,
4047
+ reponse: texte,
4048
+ suite: v.suite,
4049
+ },
4050
+ 11
4051
+ ),
4052
+ {
4053
+ horsEnveloppe: true,
4054
+ droits: horsDeLEnveloppe(ctx, REFERENT, 'sonnet'),
4055
+ id: seance,
4056
+ premiere: 'ouvre',
4057
+ }
4058
+ );
4059
+ } catch (x) {
4060
+ if (!(x instanceof Arret)) throw x;
4061
+ dire(`arrêt ${x.nom} : ${v.nom} n'entre pas dans le plan — ${premiereLigne(x.rapport)}`);
4062
+ process.stderr.write(
4063
+ `⛔ Circuit de ${ctx.ticket} arrêté : ${x.nom}, ${v.nom} n'entre pas dans le plan ; ${v.reste}.\n`
4064
+ );
4065
+ return { code: ARRETS[x.nom] };
4066
+ }
4067
+ const faits = changerLePlan(ctx, f, composant);
4068
+ if (!faits.length) {
4069
+ dire(`${v.nom} ne change pas le plan : ${lignesDesReponses(f.reponses)} ; ${v.garde}.`);
4070
+ return { plan: `inchangé, ${v.nom} ne change aucun critère`, avant };
4071
+ }
4072
+ dire(
4073
+ `${v.nom} entre dans le plan, par le référent technique (${faits.join(', ')}) :\n${lignesDesReponses(f.reponses)}`
4074
+ );
4075
+ const code =
4076
+ composantsManquants(ctx) ??
4077
+ (await questionnerLePlan(ctx, ctx.releve, { etat: e, auteur: seance }));
4078
+ if (code !== null) return { code };
4079
+ return { plan: `changé par ${v.nom} (${faits.join(', ')}), puis questionné`, avant };
4080
+ }
4081
+
4082
+ // The steps of a component's own circuit, testwriter to checks, out of the final pass.
4083
+ const DU_COMPOSANT = [TESTS, PREUVE, CODE, CONTROLES];
4084
+ // The stops whose answer is `--reponse`: a decision given there is that answer.
4085
+ const A_REPONDRE = ['question', 'questionnement', 'composants-manquants'];
4086
+ // The steps of the integration: a lot that has reached them takes no decision more.
4087
+ const INTEGREE = [INTEGRATION, EN_FILE, 'integre'];
4088
+
4089
+ // The process <cible> (a process group as -<groupe>) stopped, as a cancelled job of GitLab CI:
4090
+ // SIGTERM, then SIGKILL when it still lives after ARRET_MS; true once it is gone.
4091
+ const ARRET_MS = 10_000;
4092
+ async function terminer(cible) {
4093
+ const vit = () => {
4094
+ try {
4095
+ process.kill(cible, 0);
4096
+ return true;
4097
+ } catch (x) {
4098
+ return x.code === 'EPERM';
4099
+ }
4100
+ };
4101
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
4102
+ if (!vit()) return true;
4103
+ try {
4104
+ process.kill(cible, signal);
4105
+ } catch {
4106
+ return true;
4107
+ }
4108
+ const limite = Date.now() + ARRET_MS;
4109
+ while (vit() && Date.now() < limite) await new Promise(r => setTimeout(r, 200));
4110
+ }
4111
+ return !vit();
4112
+ }
4113
+
4114
+ // The circuit running on the ticket, the process its state <e> names (`processus`), stopped before
4115
+ // a decision of the owner enters the plan, as GitLab CI cancels a pipeline a new one makes
4116
+ // redundant and Zuul the jobs of a change superseded, said at the ticket; then the session it led
4117
+ // (`seance`, its process group), which outlives it. The state as the stopped circuit leaves it,
4118
+ // its session gone, or null without one.
4119
+ async function arreterLeCircuit(ctx, e) {
4120
+ if (!e) return null;
4121
+ const pid = e.processus;
4122
+ if (Number.isInteger(pid) && pid !== process.pid && vivant(pid)) {
4123
+ commenter(
4124
+ ctx,
4125
+ e.composant ?? ctx.composants[0],
4126
+ 'decision',
4127
+ `le circuit en cours (processus ${pid}) s'arrête, sa séance avec lui : la décision du responsable entre dans le plan, puis le circuit reprend là où il en était.`
4128
+ );
4129
+ if (!(await terminer(pid))) {
4130
+ throw new Error(`le circuit en cours (processus ${pid}) ne s'arrête pas.`);
4131
+ }
4132
+ }
4133
+ const lu = existsSync(ctx.etat) ? JSON.parse(readFileSync(ctx.etat, 'utf8')) : e;
4134
+ if (lu.seance && signalerGroupe(lu.seance, 0)) await terminer(-lu.seance);
4135
+ return { ...lu, seance: null };
4136
+ }
4137
+
4138
+ // The components of the title that the change of the plan from <avant> (the ticket before the
4139
+ // decision, the title's components then <composantsDAvant>) reaches, in the title's order: the
4140
+ // survey of the components (scripts/composants-affectes.mjs), as at the opening, of the lines the
4141
+ // change adds or removes, with each component the title gained. A change the survey situates in
4142
+ // no component reaches them all, as a pipeline runs whole on a change it cannot scope.
4143
+ function composantsDuChangement(ctx, avant, composantsDAvant) {
4144
+ const lignes = t =>
4145
+ String(t)
4146
+ .split('\n')
4147
+ .map(l => l.trim())
4148
+ .filter(Boolean);
4149
+ const maintenant = leTicket(ctx).description;
4150
+ const a = new Set(lignes(avant.description));
4151
+ const b = new Set(lignes(maintenant));
4152
+ const change = [
4153
+ ...lignes(maintenant).filter(l => !a.has(l)),
4154
+ ...lignes(avant.description).filter(l => !b.has(l)),
4155
+ ];
4156
+ const gagnes = ctx.composants.filter(c => !composantsDAvant.includes(c));
4157
+ if (!change.length) return gagnes;
4158
+ const r = releveDuTexte(
4159
+ existsSync(ctx.copie) ? ctx.copie : ctx.racine,
4160
+ `## Critères\n\n${change.join('\n')}`
4161
+ );
4162
+ const nommes = new Set(
4163
+ r.composants.flatMap(n => {
4164
+ try {
4165
+ return [ctx.resoudre(n)];
4166
+ } catch {
4167
+ return [];
4168
+ }
4169
+ })
4170
+ );
4171
+ if (!ctx.composants.some(c => nommes.has(c))) return [...ctx.composants];
4172
+ return ctx.composants.filter(c => nommes.has(c) || gagnes.includes(c));
4173
+ }
4174
+
4175
+ // The state <e> the run resumes from once a decision changed the plan: where it was, save the
4176
+ // components of <touches> it had passed (carried through, before its component, or all of them at
4177
+ // the final pass and after) and its own component past its testwriter when the change reaches it,
4178
+ // each played again from its testwriter (`renvois`, the plan changed as its report), the run
4179
+ // starting at the first of them; its own component, the change not reaching it, then resumes at
4180
+ // its step. With none, the state as it is.
4181
+ function etatApresLaDecision(ctx, e, touches) {
4182
+ const i = ctx.composants.indexOf(e.composant);
4183
+ const auComposant = DU_COMPOSANT.includes(e.etape) && e.finale !== true;
4184
+ const passe = c => !auComposant || ctx.atteints.includes(c) || ctx.composants.indexOf(c) < i;
4185
+ const rejouer = touches.filter(c => passe(c) || (c === e.composant && e.etape !== TESTS));
4186
+ const dernier = ctx.composants.at(-1);
4187
+ if (!rejouer.length) return { ...e, dernier };
4188
+ const rapport =
4189
+ "Le plan du ticket a changé depuis la séance d'avant de ce composant, par une décision du responsable que le référent technique y a versée : relis sa description et ses critères ; tes tests suivent le plan tel qu'il est.";
4190
+ const renvois = { ...(e.renvois ?? {}) };
4191
+ for (const c of rejouer) {
4192
+ renvois[c] = {
4193
+ etape: TESTS,
4194
+ testwriter: rapport,
4195
+ developpeur: null,
4196
+ contexte: renvois[c]?.contexte ?? null,
4197
+ };
4198
+ }
4199
+ const premier = Math.min(...rejouer.map(c => ctx.composants.indexOf(c)));
4200
+ if (auComposant && !rejouer.includes(e.composant) && premier < i) {
4201
+ renvois[e.composant] = {
4202
+ testwriter: null,
4203
+ developpeur: null,
4204
+ contexte: null,
4205
+ ...renvois[e.composant],
4206
+ etape: e.etape,
4207
+ [e.etape]: e.rapport ?? null,
4208
+ };
4209
+ }
4210
+ return {
4211
+ ...e,
4212
+ etape: TESTS,
4213
+ composant: ctx.composants[premier],
4214
+ tentative: 1,
4215
+ rapport,
4216
+ finale: false,
4217
+ dernier,
4218
+ renvois,
4219
+ };
4220
+ }
4221
+
4222
+ export async function main(argv) {
4223
+ let ctx = null;
4224
+ try {
4225
+ ctx = contexte(lireArguments(argv));
4226
+ mkdirSync(path.dirname(ctx.copie), { recursive: true });
4227
+ let e = existsSync(ctx.etat) ? JSON.parse(readFileSync(ctx.etat, 'utf8')) : null;
4228
+ // A decision of the owner given in session, out of any stop « question »: the circuit running
4229
+ // on the ticket stops, the decision enters the plan by the path of an answer, then the run
4230
+ // resumes where it was, the components the plan changed reaches played again
4231
+ // (etatApresLaDecision); without a state, the run starts on the plan it leaves.
4232
+ if (ctx.decision !== null) {
4233
+ const integree = x =>
4234
+ INTEGREE.includes(x?.etape) &&
4235
+ new Usage(
4236
+ `--decision : le lot de ${ctx.ticket} est à l'intégration (${x.etape}) ; une décision neuve ouvre son ticket.`
4237
+ );
4238
+ if (A_REPONDRE.includes(e?.raison)) {
4239
+ throw new Usage(
4240
+ `--decision : l'arrêt « ${e.raison} » de ${ctx.ticket} attend sa réponse, --reponse "<texte>".`
4241
+ );
4242
+ }
4243
+ if (integree(e)) throw integree(e);
4244
+ e = await arreterLeCircuit(ctx, e);
4245
+ if (integree(e)) throw integree(e);
4246
+ ctx.atteints = [...(e?.atteints ?? [])];
4247
+ ctx.verifie = e?.verifie ?? null;
4248
+ ctx.reprise = e?.reprise ?? null;
4249
+ ctx.renvois = e?.renvois ?? null;
4250
+ const titre = [...ctx.composants];
4251
+ const verse = await verserAuPlan(ctx, e, { genre: 'decision', texte: ctx.decision });
4252
+ if (verse.code !== undefined) return aLaFin(ctx, verse.code);
4253
+ if (e) {
4254
+ e = etatApresLaDecision(ctx, e, composantsDuChangement(ctx, verse.avant, titre));
4255
+ ctx.reprendre = true;
4256
+ }
4257
+ ctx.decision = null;
4258
+ }
4259
+ // The supervisor's answer to the questioning of the plan lets the plan pass, from the start.
4260
+ if (ctx.reponse !== null && e?.raison === 'questionnement') {
4261
+ commenter(
4262
+ ctx,
4263
+ ctx.composants[0],
4264
+ 'questionnement',
4265
+ `le plan, réponse du superviseur : ${ctx.reponse}`
4266
+ );
4267
+ inscrire(ctx, {
4268
+ role: AUTEUR_DU_CIRCUIT,
4269
+ composant: ctx.composants[0],
4270
+ plan: { empreinte: empreinteDuPlan(leTicket(ctx)) },
4271
+ });
4272
+ ctx.reponse = null;
4273
+ }
4274
+ // The supervisor's answer to the components the title misses lets them pass, from the start.
4275
+ if (ctx.reponse !== null && e?.raison === 'composants-manquants') {
4276
+ const hors = repondre(`${ctx.copie}.composants.json`, ctx.reponse);
4277
+ const texte = `${hors.join(', ')} hors du titre, réponse du superviseur : ${ctx.reponse}`;
4278
+ commenter(ctx, ctx.composants[0], 'composants', texte);
4279
+ ctx.reponse = null;
4280
+ }
4281
+ if (ctx.reponse !== null && (e?.raison !== 'question' || !e?.regulateur?.seance)) {
4282
+ throw new Usage(
4283
+ `--reponse : aucune séance du régulateur gardée dans ${ctx.etat} ; elle suit un arrêt « question ».`
4284
+ );
4285
+ }
4286
+ // The components the runs before carried through, the lot the whole final pass verified, and
4287
+ // the session a relaunch resumes.
4288
+ ctx.atteints = [...(e?.atteints ?? [])];
4289
+ ctx.verifie = e?.verifie ?? null;
4290
+ ctx.reprise = e?.reprise ?? null;
4291
+ let d = ctx.reponse === null ? depart(ctx, e) : null;
4292
+ // A session a dead circuit left running ends before this run launches its own.
4293
+ await attendreLaSeance(ctx, e?.seance);
4294
+ if (ctx.reponse === null) {
4295
+ // From the first testwriter: the survey of the components, then the questioning of the plan,
4296
+ // which may change the title, and so where the run starts.
4297
+ if (d.index === 0 && d.etape === TESTS) {
4298
+ // The survey names its start in the state while it runs (`releve`), which the board reads.
4299
+ ecrireEtat(ctx, {
4300
+ etape: TESTS,
4301
+ composant: ctx.composants[0],
4302
+ tentative: 1,
4303
+ code: null,
4304
+ raison: null,
4305
+ rapport: null,
4306
+ dernier: ctx.composants.at(-1),
4307
+ releve: new Date().toISOString(),
4308
+ });
4309
+ const manque = composantsManquants(ctx);
4310
+ if (manque === null) ecrireEtat(ctx, { ...ctx.courant, releve: null });
4311
+ const arret = manque ?? (await questionnerLePlan(ctx, ctx.releve));
4312
+ if (arret !== null) return aLaFin(ctx, arret);
4313
+ d = depart(ctx, e);
4314
+ }
4315
+ return aLaFin(ctx, await conduire(ctx, { depart: d }));
4316
+ }
4317
+ // The supervisor's answer enters the plan first; then it goes to the regulator's session kept
4318
+ // on the stop it settles, which judges on the plan.
4319
+ ctx.renvois = e.renvois ?? null;
4320
+ const verse = await verserAuPlan(ctx, e, {
4321
+ genre: 'reponse',
4322
+ texte: ctx.reponse,
4323
+ question: e.rapport,
4324
+ });
4325
+ if (verse.code !== undefined) return aLaFin(ctx, verse.code);
4326
+ ctx.plan = verse.plan;
4327
+ const arret = {
4328
+ code: e.regulateur.code,
4329
+ nom: e.regulateur.arret,
4330
+ rapport: e.regulateur.rapport,
4331
+ faits: e.regulateur.faits ?? {},
4332
+ ticket: e.regulateur.ticket ?? null,
4333
+ signature: e.regulateur.signature ?? undefined,
4334
+ seance: e.regulateur.seance,
4335
+ etat: {
4336
+ etape: e.etape,
4337
+ composant: e.composant,
4338
+ tentative: e.tentative,
4339
+ code: null,
4340
+ raison: null,
4341
+ rapport: e.regulateur.rapport,
4342
+ dernier: e.dernier,
4343
+ finale: e.finale === true,
4344
+ },
4345
+ };
4346
+ // A component the title gained since the question: the gesture of the table, its testwriter, the
4347
+ // stop as its context (the answer is in the plan), with no regulator resumed.
4348
+ const ajoute = ctx.composants.find(c => !(e.composants ?? ctx.composants).includes(c));
4349
+ if (ajoute) {
4350
+ const ligne = gesteDe('composant-ajoute');
4351
+ const i = ctx.composants.indexOf(ajoute);
4352
+ const decision = decisionDuGeste(ctx, ligne, i, e.rapport);
4353
+ commenter(
4354
+ ctx,
4355
+ e.composant,
4356
+ 'arbitrage',
4357
+ `${ligne.genre} → ${decision.ligne} (table des arrêts, sans régulateur).`
4358
+ );
4359
+ return aLaFin(ctx, await conduire(ctx, appliquer(ctx, arret, decision, arret.etat)));
4360
+ }
4361
+ return aLaFin(ctx, await conduire(ctx, await trancher(ctx, arret, ctx.reponse)));
4362
+ } catch (e) {
4363
+ if (!(e instanceof Usage)) throw e;
4364
+ process.stderr.write(
4365
+ `usage: npx grillhouse circuit <ticket> [--reprendre] [--depuis <testwriter|developpeur> <composant>] [--contexte <texte>] | --reponse <texte> | --decision <texte>\n⛔ ${e.message}\n`
4366
+ );
4367
+ return CODE_USAGE;
4368
+ } finally {
4369
+ ctx?.base.fermer();
4370
+ }
4371
+ }
4372
+
4373
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
4374
+ process.exitCode = await main(process.argv.slice(2));
4375
+ }