@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,381 @@
1
+ #!/usr/bin/env node
2
+ // The mechanical steps of one integration, run by the circuit of the ticket (scripts/circuit.mjs)
3
+ // once the reviewer has accepted the lot, which the circuit builds from the ticket's copy. It
4
+ // commits the lot on the branch of the ticket, or refuses it with the full output of the step that
5
+ // refused. The branch then waits for the queue (scripts/integration/file.mjs), which brings it onto
6
+ // main. Run from any working tree of the repository, it works on the main tree, found from git's
7
+ // common directory.
8
+ //
9
+ // npx grillhouse integrer --ticket <id>
10
+ // [--tests <patch>] [--code <patch>] --message <file>
11
+ //
12
+ // One delivery is one ticket: the ticket gives its branch `agent/<ticket>` and its copy
13
+ // (`.claude/worktrees/<ticket>`, the one the lot was written in, when it exists); a second
14
+ // --ticket is a usage error, named. In order, each step refusing stops the run:
15
+ // 1. the verdict: the ticket has `ACCEPTÉ` as its last reviewer verdict (`ACCEPTÉ` or
16
+ // `RENDU` at the start of a comment of the author `relecteur`; another author's is none) and, when labelled `arbitrage`, each arbitrage
17
+ // (`estArbitrage`: a « ## Arbitrage » comment, or the regulator's the circuit writes) is settled
18
+ // (`arbitrageTranche`: the regulator's, `### Verdict — tranché`, or a « ## Réponse du
19
+ // responsable » comment after it);
20
+ // 2. the lots: the tests lot touches only what the testwriter writes (`isTesterFile`: test files
21
+ // and the shared test material), the code lot none of it;
22
+ // 3. the main tree: every file of the lots is unmodified there; the ticket's copy is on its
23
+ // branch and carries the lot and nothing else: a modified or untracked file outside the lot
24
+ // refuses, named (what an ignored folder such as node_modules holds is not work);
25
+ // 4. a clean integration copy (`.claude/worktrees/integration`) on HEAD, made ready by the
26
+ // replay (scripts/integration/rejeu.mjs: submodules by scripts/sous-modules.sh, dependencies
27
+ // in agreement with the lock file and the manifests, mounts);
28
+ // 5. the lots, applied in three ways in the copy, tests first; the files of the lot in the
29
+ // ticket's copy are compared with the copy's own base plus the lot, not with the integration
30
+ // copy. Once the lots are in the copy, the replay makes the dependencies agree with the lock
31
+ // file and the manifests the lot leaves (`npm ci`, as the continuous integration runs it); an
32
+ // install that fails refuses the lot, « Les dépendances de la copie ne s'installent pas. »;
33
+ // 6. the tests: `npm run verifier` in the copy, one command (Turborepo: the build, then the
34
+ // guards, the frame's and the project's own (its root script `gardes`), and every test of the
35
+ // day beside each other, each task replayed from the cache of the
36
+ // main tree when its inputs are unchanged). Green is required: main stays green, and nothing is compared with a base. A test
37
+ // known red is put in quarantine in the test itself (`it.skip` or `it.fails`, with its
38
+ // ticket), as a mature continuous integration does; the integration admits none. A
39
+ // `npm run verifier` « à repasser » (75: a failure of duration under load, which found no rest
40
+ // in its bound, scripts/verifier.mjs) is neither green nor red: the run stops there, exit 75,
41
+ // nothing committed and nothing refused, the lot where it was, for the same command again;
42
+ // any other code than 0 refuses;
43
+ // 7. the commit in the copy, its hooks included, under GRILLHOUSE_INTEGRATION=1: a pre-commit
44
+ // hook that plays the guards skips them, as step 6 just played them; the commit validated is
45
+ // the commit that enters;
46
+ // 8. the branch `agent/<ticket>` is set on that commit, the ticket's copy follows it and is
47
+ // removed by `scripts/retirer-copie.sh` (no `--jetable`; `--garder-travail`: the working
48
+ // directory of the ticket's circuit, `<copie>.travail`, stays for the circuit that still runs,
49
+ // and the queue removes it at the ticket's closure): a refusal, a locked copy for
50
+ // example, is written to the ticket, the commit stands. The ticket is labelled
51
+ // `en-file` and the branch takes its place at the end of the queue
52
+ // (scripts/integration/file-attente.mjs). main does not move here.
53
+ //
54
+ // What steps 2, 3 and 5 read of a lot is its files: each file added, modified or deleted, a
55
+ // rename counting its source and its destination (named as the file is, not as git quotes it in
56
+ // the patch), a copy its destination only. Step 2 judges them against the testwriter's files, step 3
57
+ // against the main tree and the ticket's copy, and step 5 compares them with the copy's own base
58
+ // plus the lot.
59
+ //
60
+ // The main tree is not touched. The duration is written at the end.
61
+ // One integration runs at a time (`.git/integration.lock`): another waits for the end of the
62
+ // running one; a lock whose process is gone is taken over. Exit code: 0 committed, 1 refused,
63
+ // 2 usage, 75 à repasser.
64
+ import { spawnSync } from 'node:child_process';
65
+ import { existsSync, readFileSync } from 'node:fs';
66
+ import path from 'node:path';
67
+ import { pathToFileURL } from 'node:url';
68
+ import { arbitrageTranche, estArbitrage } from '../consommateurs-arbitres.mjs';
69
+ import { isTesterFile } from '../verrous/verrou.mjs';
70
+ import { lacherVerrou, prendreVerrou } from '../verrou-de-fichier.mjs';
71
+ import { dossierCommun, inscrireEntree } from './file-attente.mjs';
72
+ import { ouvrirCopie, RETIRER_COPIE } from './rejeu.mjs';
73
+ import { verdictDe } from './verdict.mjs';
74
+ import { A_REPASSER } from '../vitest-config/pression.mjs';
75
+ import { ouvrirBase } from '../vitest-config/base-de-passe.mjs';
76
+
77
+ // A file name as git writes it in a patch: a name with a character outside ASCII, a quote, a
78
+ // backslash or a control character comes between quotes with C escapes (`\303\251` for `é`).
79
+ // Returns the name itself.
80
+ function nomDeGit(ecrit) {
81
+ if (!ecrit.startsWith('"')) return ecrit;
82
+ const octets = [];
83
+ const echappes = { n: 10, t: 9, r: 13, a: 7, b: 8, f: 12, v: 11 };
84
+ const corps = Buffer.from(ecrit.slice(1, -1), 'utf8');
85
+ for (let i = 0; i < corps.length; i++) {
86
+ if (corps[i] !== 0x5c) {
87
+ octets.push(corps[i]);
88
+ continue;
89
+ }
90
+ const suite = String.fromCharCode(corps[i + 1]);
91
+ const octal = /^[0-7]{3}/.exec(corps.subarray(i + 1, i + 4).toString('latin1'));
92
+ if (octal) {
93
+ octets.push(Number.parseInt(octal[0], 8));
94
+ i += 3;
95
+ } else {
96
+ octets.push(echappes[suite] ?? corps[i + 1]);
97
+ i += 1;
98
+ }
99
+ }
100
+ return Buffer.from(octets).toString('utf8');
101
+ }
102
+
103
+ // The refusal for the verdicts of one ticket, or null. `ticket` and `comments` are Beads' JSON.
104
+ export function refusVerdicts(ticket, comments) {
105
+ const id = ticket.id;
106
+ const dernier = comments.filter(c => verdictDe(c)).at(-1);
107
+ if (verdictDe(dernier) !== 'ACCEPTÉ') {
108
+ return `${id} n'a pas le verdict ACCEPTÉ du relecteur pour dernier verdict.`;
109
+ }
110
+ if ((ticket.labels ?? []).includes('arbitrage')) {
111
+ const arbitrages = comments.filter(c => estArbitrage(c.text));
112
+ if (arbitrages.length === 0)
113
+ return `${id} est étiqueté arbitrage sans commentaire « Arbitrage ».`;
114
+ for (const a of arbitrages) {
115
+ if (!arbitrageTranche(comments, a)) {
116
+ const titre = a.text.trim().split('\n')[0];
117
+ return `${id} : « ${titre} » n'est ni tranché ni répondu par le responsable.`;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // The refusal for the files of the lots, or null. The files are relative to the repository root
125
+ // <racine>; the tests lot holds only what the testwriter writes, the code lot none of it.
126
+ export function refusLots(tests, code, racine) {
127
+ const testwriter = f => isTesterFile(f, racine);
128
+ const horsTests = tests.filter(f => !testwriter(f));
129
+ if (horsTests.length) return `Le lot de tests touche du code : ${horsTests.join(', ')}.`;
130
+ const duTestwriter = code.filter(testwriter);
131
+ if (duTestwriter.length)
132
+ return `Le lot de code touche des fichiers du testwriter (tests ou matière de test partagée) : ${duTestwriter.join(', ')}.`;
133
+ return null;
134
+ }
135
+
136
+ // The options, or { faute } for a usage error.
137
+ function lire(argv) {
138
+ const o = { ticket: null, tests: null, code: null, message: null };
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const v = argv[i + 1];
141
+ switch (argv[i]) {
142
+ case '--ticket':
143
+ if (o.ticket !== null)
144
+ return {
145
+ faute: `un seul --ticket : ${v} en est un second, et une livraison est un ticket.`,
146
+ };
147
+ o.ticket = v;
148
+ i++;
149
+ break;
150
+ case '--tests':
151
+ o.tests = v;
152
+ i++;
153
+ break;
154
+ case '--code':
155
+ o.code = v;
156
+ i++;
157
+ break;
158
+ case '--message':
159
+ o.message = v;
160
+ i++;
161
+ break;
162
+ default:
163
+ return { faute: null };
164
+ }
165
+ }
166
+ if (!o.ticket || !o.message || !(o.tests || o.code)) return { faute: null };
167
+ return o;
168
+ }
169
+
170
+ // The files of the ticket's copy that are not the lot: every modified, deleted or untracked file
171
+ // (ignored ones do not count) the lot does not name.
172
+ function horsLot(copieTicket, fichiers) {
173
+ const r = spawnSync(
174
+ 'git',
175
+ ['-C', copieTicket, 'status', '--porcelain=v1', '-z', '-uall', '--no-renames'],
176
+ { encoding: 'utf8' }
177
+ );
178
+ if (r.status !== 0) return null;
179
+ const dans = new Set(fichiers);
180
+ return r.stdout
181
+ .split('\0')
182
+ .filter(Boolean)
183
+ .map(l => l.slice(3))
184
+ .filter(f => !dans.has(f));
185
+ }
186
+
187
+ // The files of the lot whose content in the ticket's copy differs from its own base plus the lot:
188
+ // the lots are applied to the copy's HEAD in a temporary index, which the copy's files are compared
189
+ // to. Every file of the lot differs when the lots do not apply to that base.
190
+ function differents(copieTicket, lots, fichiers, tmp) {
191
+ const index = path.join(tmp, 'index-copie');
192
+ const env = { ...process.env, GIT_INDEX_FILE: index };
193
+ const dansCopie = (...args) =>
194
+ spawnSync('git', ['-C', copieTicket, ...args], { encoding: 'utf8', env });
195
+ if (dansCopie('read-tree', 'HEAD').status !== 0) return fichiers;
196
+ for (const p of lots) if (dansCopie('apply', '--cached', p).status !== 0) return fichiers;
197
+ const r = dansCopie('diff', '--name-only', '--no-renames', '-z', '--', ...fichiers);
198
+ if (r.status !== 0) return fichiers;
199
+ return r.stdout.split('\0').filter(Boolean);
200
+ }
201
+
202
+ function main(argv) {
203
+ const o = lire(argv);
204
+ if ('faute' in o) {
205
+ process.stderr.write(
206
+ 'usage: integrer.mjs --ticket <id> [--tests <patch>] [--code <patch>] --message <file>\n' +
207
+ (o.faute ? `⛔ ${o.faute}\n` : '')
208
+ );
209
+ return 2;
210
+ }
211
+ // The main tree, from git's common directory: run from a copy, the integration still works on it.
212
+ const racine = path.dirname(
213
+ spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
214
+ encoding: 'utf8',
215
+ }).stdout.trim()
216
+ );
217
+ const git = (...args) => spawnSync('git', ['-C', racine, ...args], { encoding: 'utf8' });
218
+ const bd = (...a) =>
219
+ spawnSync('bd', a, { cwd: racine, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
220
+ const dire = s => process.stdout.write(`${s}\n`);
221
+ const refuser = s => {
222
+ process.stdout.write(`⛔ ${s}\nLe lot retourne à son agent avec cette sortie.\n`);
223
+ return 1;
224
+ };
225
+ const absolu = p => path.resolve(p);
226
+
227
+ // One integration at a time: a second waits for the end of the first; a lock whose process is
228
+ // gone is taken over.
229
+ const verrou = path.join(dossierCommun(racine), 'integration.lock');
230
+ prendreVerrou(verrou, { attendre: true });
231
+ const debut = Date.now();
232
+ // The working directory of the integration: a base of its own, removed at its end, swept by the
233
+ // next one to open a base when the integration is killed (../vitest-config/base-de-passe.mjs).
234
+ const base = ouvrirBase('integration');
235
+ const tmp = base.dossier;
236
+ try {
237
+ // 1. The verdict.
238
+ const show = bd('show', o.ticket, '--json');
239
+ const comments = bd('comments', o.ticket, '--json');
240
+ if (show.status !== 0 || comments.status !== 0)
241
+ return refuser(`Ticket ${o.ticket} illisible : ${show.stderr}${comments.stderr}`);
242
+ const r1 = refusVerdicts(JSON.parse(show.stdout)[0], JSON.parse(comments.stdout || '[]'));
243
+ if (r1) return refuser(r1);
244
+ dire('✓ verdicts');
245
+
246
+ // 2. The lots.
247
+ const lots = [o.tests, o.code].filter(Boolean).map(absolu);
248
+ const fichiersDe = p => {
249
+ const r = git('apply', '--numstat', '-z', p);
250
+ if (r.status !== 0) return null;
251
+ const destinations = r.stdout
252
+ .split('\0')
253
+ .map(l => l.split('\t')[2])
254
+ .filter(Boolean);
255
+ // A rename names its source too (numstat names only the destination); a copy leaves its
256
+ // source intact, so only its destination counts.
257
+ const sources = [...readFileSync(p, 'utf8').matchAll(/^rename from (.+)$/gm)].map(m =>
258
+ nomDeGit(m[1])
259
+ );
260
+ return [...destinations, ...sources];
261
+ };
262
+ const tests = o.tests ? fichiersDe(absolu(o.tests)) : [];
263
+ const code = o.code ? fichiersDe(absolu(o.code)) : [];
264
+ if (!tests || !code) return refuser('Un patch du lot est illisible.');
265
+ const r2 = refusLots(tests, code, racine);
266
+ if (r2) return refuser(r2);
267
+ const fichiers = [...new Set([...tests, ...code])];
268
+ dire(`✓ lots : ${fichiers.length} fichiers`);
269
+
270
+ // 3. The main tree: the lots' files are free there, so the queue can bring them. The ticket's
271
+ // copy holds the lot and nothing else.
272
+ const tenus = git('status', '--porcelain', '--', ...fichiers).stdout.trim();
273
+ if (tenus) return refuser(`Des fichiers du lot sont modifiés dans l'arbre :\n${tenus}`);
274
+ dire('✓ arbre principal libre');
275
+ const branche = `agent/${o.ticket}`;
276
+ const copieTicket = path.join(racine, '.claude', 'worktrees', o.ticket);
277
+ // A ticket without a copy (none was made, or it is gone) has nothing to check nor to remove.
278
+ const avecCopie = existsSync(copieTicket);
279
+ if (avecCopie) {
280
+ const surBranche = spawnSync(
281
+ 'git',
282
+ ['-C', copieTicket, 'rev-parse', '--abbrev-ref', 'HEAD'],
283
+ { encoding: 'utf8' }
284
+ );
285
+ if (surBranche.stdout.trim() !== branche) {
286
+ return refuser(`La copie du ticket ${copieTicket} n'est pas sur la branche ${branche}.`);
287
+ }
288
+ const autres = horsLot(copieTicket, fichiers);
289
+ if (autres === null) return refuser(`L'état de la copie ${copieTicket} ne se lit pas.`);
290
+ if (autres.length) {
291
+ return refuser(
292
+ `La copie du ticket porte autre chose que le lot :\n${autres.map(f => ` ${f}`).join('\n')}`
293
+ );
294
+ }
295
+ dire('✓ copie du ticket : le lot seul');
296
+ }
297
+
298
+ // 4. The integration copy: detached on HEAD, without any untracked file; its dependencies
299
+ // are kept only while the lock file and the manifests are the same (a CI cache keyed by them).
300
+ const copie = path.join(racine, '.claude', 'worktrees', 'integration');
301
+ const ouverte = ouvrirCopie({ racine, copie, ref: 'HEAD', dire });
302
+ if (ouverte.refus) return refuser(ouverte.refus);
303
+ const { dansCopie, monter, installer, tester } = ouverte;
304
+
305
+ // 5. The lots, applied in the copy, then installed.
306
+ for (const p of lots) {
307
+ const r = dansCopie('apply', '--3way', '--index', p);
308
+ if (r.status !== 0) return refuser(`Le lot ${p} ne s'applique pas :\n${r.stdout}${r.stderr}`);
309
+ }
310
+ const divergents = avecCopie ? differents(copieTicket, lots, fichiers, tmp) : [];
311
+ if (divergents.length) {
312
+ return refuser(
313
+ `Des fichiers du lot diffèrent dans la copie du ticket :\n${divergents.map(f => ` ${f}`).join('\n')}`
314
+ );
315
+ }
316
+ dire('✓ lots appliqués');
317
+ const installation = installer();
318
+ if (installation) return refuser(installation);
319
+
320
+ // 6. The tests: the build, the guards and every test of the day, green required.
321
+ dire('— npm run verifier (construction, gardes, tests)');
322
+ const verdict = tester().status;
323
+ if (verdict === A_REPASSER) {
324
+ dire(
325
+ `⏱ À repasser (code ${A_REPASSER}) : un échec de durée sous charge, sans repos dans la borne. Le lot n'est ni commis ni refusé ; relance la même commande.`
326
+ );
327
+ return A_REPASSER;
328
+ }
329
+ if (verdict !== 0) return refuser('Les tests refusent le lot (sortie ci-dessus).');
330
+ dire('✓ tests');
331
+
332
+ // 7. The commit, in the copy: the commit validated is the commit that enters.
333
+ // The guards have just run (in `npm run verifier`): a pre-commit hook that plays them skips them under
334
+ // GRILLHOUSE_INTEGRATION=1; commit-msg (a closed ticket refuses a second commit, a reopened one takes its corrections) runs as always.
335
+ const c = spawnSync(
336
+ 'bash',
337
+ monter('git', '-C', copie, 'commit', '-q', '-F', absolu(o.message)),
338
+ {
339
+ encoding: 'utf8',
340
+ env: { ...process.env, GRILLHOUSE_INTEGRATION: '1' },
341
+ }
342
+ );
343
+ if (c.status !== 0) return refuser(`Le commit est refusé :\n${c.stdout}${c.stderr}`);
344
+ const commit = dansCopie('rev-parse', 'HEAD').stdout.trim();
345
+
346
+ // 8. The branch of the ticket takes the commit; the ticket's copy follows it (it carried the
347
+ // lot, which the commit now holds) and is removed; the ticket waits in the queue.
348
+ const pose = git('update-ref', `refs/heads/${branche}`, commit);
349
+ if (pose.status !== 0) {
350
+ return refuser(`La branche ${branche} ne prend pas ${commit.slice(0, 7)} :\n${pose.stderr}`);
351
+ }
352
+ if (avecCopie) {
353
+ spawnSync('git', ['-C', copieTicket, 'reset', '-q', '--hard', commit], { encoding: 'utf8' });
354
+ }
355
+ bd('update', o.ticket, '--add-label', 'en-file');
356
+ dire(`✓ commit ${commit.slice(0, 7)} sur ${branche}`);
357
+ const retrait = avecCopie
358
+ ? spawnSync('bash', [RETIRER_COPIE, '--garder-travail', copieTicket], {
359
+ cwd: racine,
360
+ encoding: 'utf8',
361
+ })
362
+ : { status: 0, stderr: '' };
363
+ if (retrait.status !== 0) {
364
+ const raison = `La copie ${copieTicket} n'est pas retirée après le commit de ${branche} : ${retrait.stderr.trim()}\nLa branche reste en file.`;
365
+ bd('comments', 'add', o.ticket, raison);
366
+ dire(`⚠ ${raison}`);
367
+ }
368
+ inscrireEntree(racine, branche, o.ticket);
369
+ dire('✓ en file');
370
+ const duree = Math.round((Date.now() - debut) / 100) / 10;
371
+ dire(`⏱ ${duree} s`);
372
+ return 0;
373
+ } finally {
374
+ base.fermer();
375
+ lacherVerrou(verrou);
376
+ }
377
+ }
378
+
379
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
380
+ process.exitCode = main(process.argv.slice(2));
381
+ }
@@ -0,0 +1,139 @@
1
+ // The replay of a lot in a clean copy, shared by the integration (scripts/integration/integrer.mjs,
2
+ // a lot on the main of the moment) and the queue (scripts/integration/file.mjs, a branch replaced
3
+ // on the main of the moment): the copy made ready, the project's commands, its tests.
4
+ //
5
+ // - `ouvrirCopie` makes the copy <copie> ready on <ref>: detached, every untracked file removed, its
6
+ // submodules set (`scripts/sous-modules.sh`, a local modification overwritten; a submodule the
7
+ // project shares, `grillhouse.partage`, is linked to the main repository's instead), its
8
+ // dependencies made to agree with its lock file and manifests (below). It builds nothing: the
9
+ // tests build. An existing copy that does not get ready is removed by
10
+ // `scripts/retirer-copie.sh --jetable` (local work does not hold it, a lock does) and created
11
+ // again, once; a copy that does not get ready the second time refuses, and so does a removal the
12
+ // script refuses, with its reason. Every command of the project plays through
13
+ // `scripts/sous-modules.sh monter`: each submodule the preparation linked is read-only, by the
14
+ // list the pose printed, whatever a command rewrites afterwards. With a link and no possible
15
+ // mount (bwrap absent or refused), the copy refuses before any command runs.
16
+ // - The dependencies follow the lock file and the manifests of the moment. One function (`accorder`,
17
+ // which `ouvrirCopie` hands out as `installer`) compares the fingerprint of the lock file and of
18
+ // the manifests (every `package.json` git tracks) with the key
19
+ // `node_modules/.integration-lock`, runs `npm ci` when they differ and writes the key again; a
20
+ // failing install gives `DEPENDANCES_REFUSEES`, the one form of that refusal. It runs at the
21
+ // opening, and the consumer calls `installer` once the lot is in the copy, before the tests: a
22
+ // lot that changes the lock file or a manifest is installed as the continuous integration
23
+ // installs it (`npm ci` refuses a lock file in disagreement with its manifest). The tests run on
24
+ // dependencies in agreement, and they neither read the key nor install.
25
+ // - `tester` plays `npm run verifier` in the copy: Turborepo builds the project, then plays its
26
+ // guards and its tests beside each other (`turbo.json`), each task kept in the cache of the main
27
+ // tree (`.turbo/cache`, shared by every copy), so a task whose inputs an earlier run already
28
+ // played replays its log. It gives the command's result, green (status 0), « à repasser »
29
+ // (75, scripts/verifier.mjs) or red (any other), its output written as it comes.
30
+ // - Every command of the project run in the copy (`lancer`: the install, the tests) leaves the
31
+ // index of its copy up to date, green or red: the consumers that apply a lot with `--index` (the
32
+ // integration, the queue) read an index that matches the files the command touched, and none
33
+ // refreshes for its own account.
34
+ import { spawnSync } from 'node:child_process';
35
+ import { createHash } from 'node:crypto';
36
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const SOUS_MODULES = fileURLToPath(new URL('../sous-modules.sh', import.meta.url));
41
+ export const RETIRER_COPIE = fileURLToPath(new URL('../retirer-copie.sh', import.meta.url));
42
+
43
+ const VERROU = 'package-lock.json';
44
+ const DEPENDANCES_REFUSEES = "Les dépendances de la copie ne s'installent pas.";
45
+ // The most output a command of the project may write: a whole run of the tests, logs included.
46
+ const SORTIE_MAX = 256 * 1024 * 1024;
47
+
48
+ // The fingerprint of what `npm ci` reads in <copie>: the lock file at <verrou> and every
49
+ // `package.json` git tracks (the root's and each workspace's, whatever pattern declares it: a
50
+ // manifest too many costs one more `npm ci`, a manifest missed lets a disagreement pass).
51
+ // `dansCopie` is git in the copy.
52
+ function empreinteDesDependances(copie, verrou, dansCopie) {
53
+ const suivis = dansCopie('ls-files', '-z', '--', ':(glob)**/package.json')
54
+ .stdout.split('\0')
55
+ .filter(Boolean)
56
+ .sort();
57
+ const h = createHash('sha256').update(readFileSync(verrou));
58
+ for (const f of suivis) h.update(`\0${f}\0`).update(readFileSync(path.join(copie, f)));
59
+ return h.digest('hex');
60
+ }
61
+
62
+ // The copy <copie> ready on <ref>, or `{ refus }`. `dire` writes a progress line. Returns the
63
+ // copy's tools: `dansCopie` (git in the copy), `monter` (the command line of a mounted command),
64
+ // `installer`, `tester`, and `tete`, the commit the copy was made ready on.
65
+ export function ouvrirCopie({ racine, copie, ref, dire }) {
66
+ const git = (...args) => spawnSync('git', ['-C', racine, ...args], { encoding: 'utf8' });
67
+ const dansCopie = (...args) => spawnSync('git', ['-C', copie, ...args], { encoding: 'utf8' });
68
+ const tete = git('rev-parse', ref).stdout.trim();
69
+ let partages = '';
70
+ const preparer = () => {
71
+ const prete = existsSync(copie)
72
+ ? dansCopie('checkout', '-q', '--detach', '--force', tete).status === 0 &&
73
+ dansCopie('clean', '-q', '-f', '-d', '-x', '-e', 'node_modules').status === 0
74
+ : git('worktree', 'add', '-q', '--detach', copie, tete).status === 0;
75
+ if (!prete) return `La copie ${copie} ne se prépare pas.`;
76
+ const poses = spawnSync('bash', [SOUS_MODULES, 'pose', copie], { encoding: 'utf8' });
77
+ if (poses.status !== 0) return `La copie ne pose pas ses sous-modules : ${poses.stderr.trim()}`;
78
+ partages = poses.stdout;
79
+ return null;
80
+ };
81
+ const existait = existsSync(copie);
82
+ let refus = preparer();
83
+ if (refus && existait) {
84
+ const retrait = spawnSync('bash', [RETIRER_COPIE, '--jetable', copie], {
85
+ cwd: racine,
86
+ encoding: 'utf8',
87
+ });
88
+ if (retrait.status !== 0) {
89
+ return { refus: `${refus}\nLa copie ne se retire pas : ${retrait.stderr.trim()}` };
90
+ }
91
+ refus = preparer();
92
+ }
93
+ if (refus) return { refus };
94
+ const monter = (...commande) => [
95
+ SOUS_MODULES,
96
+ 'monter',
97
+ copie,
98
+ '--partages',
99
+ partages,
100
+ ...commande,
101
+ ];
102
+ const montage = spawnSync('bash', monter('true'), { encoding: 'utf8' });
103
+ if (montage.status !== 0) return { refus: montage.stderr.trim() };
104
+ const lancer = (commande, args) => {
105
+ const r = spawnSync('bash', monter(commande, ...args), {
106
+ cwd: copie,
107
+ encoding: 'utf8',
108
+ maxBuffer: SORTIE_MAX,
109
+ });
110
+ process.stdout.write(r.stdout + r.stderr);
111
+ // After every command, green or red, `git update-index -q --refresh` re-reads the stat data of
112
+ // the files it touched (a hard link changes an inode date), as git does before it judges a
113
+ // tree. A refresh that fails changes nothing to the result, which is the command's.
114
+ dansCopie('update-index', '-q', '--refresh');
115
+ return r;
116
+ };
117
+ const npm = args => lancer('npm', args);
118
+ // The one point that makes the dependencies agree with the lock file and the manifests of the
119
+ // moment: the key of node_modules is compared with their fingerprint, `npm ci` runs when they
120
+ // differ, the key is written again. Null when they agree, else the refusal.
121
+ const accorder = () => {
122
+ const verrouNpm = path.join(copie, VERROU);
123
+ if (!existsSync(verrouNpm)) return null;
124
+ const cle = path.join(copie, 'node_modules', '.integration-lock');
125
+ const empreinte = empreinteDesDependances(copie, verrouNpm, dansCopie);
126
+ if (existsSync(cle) && readFileSync(cle, 'utf8') === empreinte) return null;
127
+ if (npm(['ci', '--no-audit', '--no-fund', '--silent']).status !== 0)
128
+ return DEPENDANCES_REFUSEES;
129
+ writeFileSync(cle, empreinte);
130
+ return null;
131
+ };
132
+ const refusOuverture = accorder();
133
+ if (refusOuverture) return { refus: refusOuverture };
134
+ dire(`✓ copie propre sur ${tete.slice(0, 7)}`);
135
+
136
+ // The checks of the copy: Turborepo builds, then plays the guards and the tests.
137
+ const tester = () => npm(['run', 'verifier']);
138
+ return { dansCopie, monter, installer: accorder, tester, tete };
139
+ }
@@ -0,0 +1,13 @@
1
+ // The reviewer's verdict at a ticket, as the circuit, the integration and the board read it: a
2
+ // module of its own, which imports nothing, so the board reads a verdict without the integration.
3
+
4
+ // The first word of a reviewer's verdict: ACCEPTÉ or RENDU.
5
+ export const VERDICT = /^\s*(ACCEPTÉ|RENDU)(?![\p{L}\d])/u;
6
+ // The author of the reviewer's verdicts at a ticket (Beads' `author`): the circuit writes the
7
+ // reviewer's final answer under it (BEADS_ACTOR), and the reviewer's session runs under it.
8
+ export const AUTEUR_DU_VERDICT = 'relecteur';
9
+
10
+ // The verdict the comment <c> (Beads' JSON: `author`, `text`) carries: ACCEPTÉ or RENDU for a
11
+ // comment of the author relecteur whose first word is one of them, else null.
12
+ export const verdictDe = c =>
13
+ c?.author === AUTEUR_DU_VERDICT ? (VERDICT.exec(String(c.text ?? ''))?.[1] ?? null) : null;