@roomi-fields/grillhouse 0.23.0 → 0.24.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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/METACADRE.md +1 -1
  3. package/agents/explorateur.md +4 -2
  4. package/agents/questionneur.md +1 -1
  5. package/bin/grillhouse.mjs +3 -58
  6. package/cadre/templates/projet/CLAUDE.md +5 -1
  7. package/cadre/templates/projet/docs/agents/issue-tracker.md +4 -4
  8. package/charte.md +23 -6
  9. package/docs/ARCHITECTURE.md +127 -1
  10. package/hooks/register.tsx +3 -13
  11. package/package.json +13 -1
  12. package/scripts/bilan.mjs +134 -88
  13. package/scripts/circuit.mjs +1547 -2248
  14. package/scripts/composants-affectes.mjs +27 -108
  15. package/scripts/consommateurs-arbitres.mjs +15 -21
  16. package/scripts/consommateurs.d.ts +8 -0
  17. package/scripts/consommateurs.mjs +5 -1
  18. package/scripts/dette.d.ts +23 -0
  19. package/scripts/dette.mjs +76 -13
  20. package/scripts/droits.mjs +38 -44
  21. package/scripts/elements-du-lot.mjs +246 -0
  22. package/scripts/enveloppe/enveloppe.mjs +105 -232
  23. package/scripts/enveloppe/lancer.sh +79 -49
  24. package/scripts/enveloppe/paquets-du-plan.mjs +49 -0
  25. package/scripts/etapes.mjs +148 -213
  26. package/scripts/gardes-du-cadre.mjs +8 -6
  27. package/scripts/grillhouse-maj.mjs +31 -9
  28. package/scripts/index-interfaces.mjs +21 -1
  29. package/scripts/integration/integrer.mjs +2 -2
  30. package/scripts/interfaces-contre-code.mjs +25 -17
  31. package/scripts/migrations/0.23.0-config-vitest.mjs +26 -23
  32. package/scripts/migrations/0.23.2-copies-du-cadre.mjs +201 -0
  33. package/scripts/migrations/0.24.0-circuit-simplifie.mjs +231 -0
  34. package/scripts/migrations.mjs +41 -6
  35. package/scripts/paquets.d.ts +5 -0
  36. package/scripts/paquets.mjs +3 -0
  37. package/scripts/references-par-les-types.mjs +55 -36
  38. package/scripts/registre.mjs +7 -15
  39. package/scripts/resultats.mjs +283 -142
  40. package/scripts/resumes.mjs +1 -1
  41. package/scripts/session-start.sh +1 -2
  42. package/scripts/sortie.mjs +1 -1
  43. package/scripts/sources.mjs +28 -0
  44. package/scripts/sous-commandes.mjs +62 -0
  45. package/scripts/tableau.mjs +85 -191
  46. package/scripts/tests.mjs +19 -17
  47. package/scripts/verifier.mjs +5 -4
  48. package/scripts/verrou-de-machine.mjs +131 -24
  49. package/scripts/verrous/verrou.mjs +66 -5
  50. package/skills/developpeur/SKILL.md +60 -50
  51. package/skills/explorateur/SKILL.md +90 -64
  52. package/skills/grill/SKILL.md +1 -1
  53. package/skills/mesure/SKILL.md +2 -1
  54. package/skills/pitmaster/SKILL.md +132 -118
  55. package/skills/pitmaster/references/consigne-agent.md +54 -148
  56. package/skills/questionneur/SKILL.md +18 -18
  57. package/skills/relecteur/SKILL.md +69 -23
  58. package/skills/testwriter/SKILL.md +46 -35
  59. package/types/index.d.ts +1 -3
  60. package/agents/referent-technique.md +0 -9
  61. package/agents/regulateur.md +0 -11
  62. package/scripts/enveloppe/composant.mjs +0 -11
  63. package/scripts/etat-du-circuit.mjs +0 -104
  64. package/skills/referent-technique/SKILL.md +0 -145
  65. package/skills/regulateur/SKILL.md +0 -220
package/scripts/tests.mjs CHANGED
@@ -37,21 +37,23 @@
37
37
  // Turborepo's `--force` re-executes every task of the run, so the variable carries the moment of
38
38
  // the run instead (`rejouer`), a value no cached run of the tests has. A test suspected unstable
39
39
  // is played again, and a build, which the same inputs give again, is not.
40
- // - In the envelope of an agent (`GRILLHOUSE_ENVELOPPE`, scripts/enveloppe/composant.mjs), the
41
- // neighbours and their traces arrive built by the launcher and read-only, as in a sandbox of
42
- // Bazel: the build is the named packages' alone, with the traces of those packages their tests
43
- // wait for, which live in the package the agent writes (`turbo run build <name>#trace:<t>…
44
- // --only --filter=<name>`, their task ids read in Turborepo's plan of the tests,
45
- // `turbo run test:vitest --filter=<name> --dry=json`), never their dependencies nor a
46
- // neighbour's trace; then the tests alone (`turbo run test:vitest --only --filter=<name>`), the
47
- // paths in the variable. A named package that the envelope shows read-only (the shared test
48
- // material, for every role but the testwriter) is not built, nor are its traces: it arrives built,
49
- // as a read-only input of Bazel, and its tests play as it is; the frame knows it read-only by
50
- // testing its directory for writing (`inscriptible`), where the envelope mounts it, and the logs
51
- // of its tasks go to the envelope's directories proper to the session (`sortiesDeTest`,
52
- // scripts/enveloppe/enveloppe.mjs), never into its source. There the hash of the tests sees
53
- // neither the code of the neighbours nor their builds: the verdict that counts is the reviewer's
54
- // and the integration's, out of the envelope.
40
+ // - In the envelope of an agent (`GRILLHOUSE_ENVELOPPE`, scripts/enveloppe/paquets-du-plan.mjs),
41
+ // the packages out of its plan and their traces arrive built by the launcher and read-only, as
42
+ // in a sandbox of Bazel: the build is the named packages' alone, with the traces of those
43
+ // packages their tests wait for, which live in the package the agent writes
44
+ // (`turbo run build <name>#trace:<t>… --only --filter=<name>`, their task ids read in
45
+ // Turborepo's plan of the tests, `turbo run test:vitest --filter=<name> --dry=json`), never
46
+ // their dependencies nor a neighbour's trace; then the tests alone
47
+ // (`turbo run test:vitest --only --filter=<name>`), the paths in the variable. A named package
48
+ // that the envelope shows read-only (one out of the plan; the shared test material, for every
49
+ // role but the testwriter) is not built, nor are its traces: it arrives built, as a read-only
50
+ // input of Bazel; the frame knows it read-only by testing its directory for writing
51
+ // (`inscriptible`), where the envelope mounts it. The shared test material plays its tests as
52
+ // it is: the logs of its tasks go to the envelope's directories proper to the session
53
+ // (`sortiesDeTest`, scripts/enveloppe/enveloppe.mjs), never into its source. A package out of
54
+ // the plan has no such directories: Turborepo and Vite cannot write its logs and its cache, and
55
+ // a run of its tests fails on « Read-only file system »; its tests play out of the envelope. The
56
+ // verdict that counts is the reviewer's and the integration's, out of the envelope.
55
57
  // - An argument that begins with « - » is an option of Vitest, written `--<name>=<value>`, carried
56
58
  // in the variable beside the paths.
57
59
  // - A night test or a time budget bench, which the day leaves out, plays when a path names its own
@@ -79,7 +81,7 @@ import { spawnSync } from 'node:child_process';
79
81
  import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs';
80
82
  import path from 'node:path';
81
83
  import { planifier } from './avancement.mjs';
82
- import { composantDeLEnveloppe } from './enveloppe/composant.mjs';
84
+ import { paquetsDeLEnveloppe } from './enveloppe/paquets-du-plan.mjs';
83
85
  import { paquets } from './paquets.mjs';
84
86
  import { garderLesResumes, RESULTATS } from './resultats.mjs';
85
87
  import { tracesDuPlan } from './turbo-des-paquets.mjs';
@@ -94,7 +96,7 @@ const racine = process.cwd();
94
96
  // Turborepo write in it (TMPDIR), and it goes at the end of the run.
95
97
  const base = ouvrirBase('tests');
96
98
  process.env.TMPDIR = base.dossier;
97
- const enveloppe = composantDeLEnveloppe() !== null;
99
+ const enveloppe = paquetsDeLEnveloppe() !== null;
98
100
  const args = process.argv.slice(2);
99
101
  const force = args.includes('--force');
100
102
  const options = args.filter(a => a.startsWith('-') && a !== '--force');
@@ -23,7 +23,8 @@
23
23
  // shared by every repository of the machine: a pass that finds it held waits for the end of the
24
24
  // other measure, and says so every 30 s with who holds it. The wait bounded (60 min), the pass
25
25
  // ends at 75, « à repasser »; a lock held by an ancestor of the pass ends it at once, red (1);
26
- // either way without playing, the holder named.
26
+ // either way without playing, the holder named. A pass in the frame's own repository is of the
27
+ // second rank: it waits while a project's measure waits too.
27
28
  // - Turborepo plays the build, then the guards (the frame's, `grillhouse:gardes`, and the
28
29
  // project's own, its root script `gardes`) and the tests (`test:vitest`) beside each other:
29
30
  // `turbo run grillhouse:gardes gardes test:vitest --continue=dependencies-successful
@@ -92,7 +93,7 @@ import { availableParallelism, tmpdir } from 'node:os';
92
93
  import path from 'node:path';
93
94
  import { pathToFileURL } from 'node:url';
94
95
  import { ecrireLaMesure, garderLeTotal, ouvrirAvancement, planifier } from './avancement.mjs';
95
- import { composantDeLEnveloppe } from './enveloppe/composant.mjs';
96
+ import { paquetsDeLEnveloppe } from './enveloppe/paquets-du-plan.mjs';
96
97
  import { paquets } from './paquets.mjs';
97
98
  import { AttenteDeLaMachine, avecLaMachine } from './verrou-de-machine.mjs';
98
99
  import {
@@ -197,7 +198,7 @@ export function verifier({
197
198
  const dossier =
198
199
  envMachine[AVANCEMENT] || ouvrirAvancement({ racine, genre: 'verification' });
199
200
  const effacer =
200
- propre && !composantDeLEnveloppe(envMachine)
201
+ propre && !paquetsDeLEnveloppe(envMachine)
201
202
  ? ecrireLaMesure({
202
203
  env: envMachine,
203
204
  depot: nomDuDepot(racine),
@@ -226,7 +227,7 @@ export function verifier({
226
227
  }
227
228
  }
228
229
  },
229
- { env, dire }
230
+ { env, racine, dire }
230
231
  );
231
232
  } catch (e) {
232
233
  if (!(e instanceof AttenteDeLaMachine)) throw e;
@@ -31,21 +31,59 @@
31
31
  // variable GRILLHOUSE_MESURE_TENUE carries the mark of the taking, and a process whose variable
32
32
  // names the mark the lock holds plays under it without taking it again (the final pass of the
33
33
  // circuit, `npm run verifier`, under the circuit's lock).
34
- // - In the envelope of an agent (GRILLHOUSE_ENVELOPPE set, scripts/enveloppe/composant.mjs) no lock
35
- // is taken: the envelope has its own process space, where an owner's number means nothing
34
+ // - In the envelope of an agent (GRILLHOUSE_ENVELOPPE set, scripts/enveloppe/paquets-du-plan.mjs)
35
+ // no lock is taken: the envelope has its own process space, where an owner's number means nothing
36
36
  // outside, and its home shows no cache.
37
+ // - A measure has a rank, as the job server of GNU make serves its own build first and Bazel's
38
+ // `--experimental_local_test_priority` orders the tests that wait for the machine. A measure
39
+ // played in the frame's own repository (one whose workspaces hold the package
40
+ // @roomi-fields/grillhouse in development, its agent copies included: `rangDeLaMesure`) is of
41
+ // the second rank; a project's (a circuit, a `npm run verifier`) is of the first. A measure of
42
+ // the first rank that waits names itself in the directory `<lock>.premier-rang/` (one file per
43
+ // waiting process, named by its number, removed once it holds the lock or stops); a measure of
44
+ // the second rank does not take the lock, even free, while a live process waits there, so a
45
+ // project's circuit never waits behind the frame's own checks (BPScript 320.9: 208 min of its
46
+ // wait went to the checks of the frame's agent copies). A waiting file whose process is gone
47
+ // names no one and goes. Between measures of one rank, the first to find the lock free takes
48
+ // it. The bound of the wait is the same for both ranks, and the wait says its rank.
37
49
  // - The lock is let go at the end of the measure, whether it succeeds or throws.
38
- import { mkdirSync, readFileSync, readlinkSync, statSync } from 'node:fs';
50
+ import { randomUUID } from 'node:crypto';
51
+ import {
52
+ mkdirSync,
53
+ readdirSync,
54
+ readFileSync,
55
+ readlinkSync,
56
+ rmSync,
57
+ statSync,
58
+ writeFileSync,
59
+ } from 'node:fs';
39
60
  import { homedir } from 'node:os';
40
61
  import path from 'node:path';
41
- import { composantDeLEnveloppe } from './enveloppe/composant.mjs';
62
+ import { paquetsDeLEnveloppe } from './enveloppe/paquets-du-plan.mjs';
63
+ import { paquets } from './paquets.mjs';
42
64
  import { attendreUnPeu, lacherVerrou, prendreVerrou } from './verrou-de-fichier.mjs';
65
+ import { vivant } from './vitest-config/base-de-passe.mjs';
43
66
  import { A_REPASSER } from './vitest-config/pression.mjs';
44
67
 
45
68
  const VARIABLE_VERROU = 'GRILLHOUSE_VERROU_MACHINE';
46
69
  const VARIABLE_TENUE = 'GRILLHOUSE_MESURE_TENUE';
47
70
  const VARIABLE_ATTENTE = 'GRILLHOUSE_ATTENTE_MACHINE_MS';
48
71
 
72
+ // The name of the frame's package, which a repository of the second rank develops.
73
+ const CADRE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).name;
74
+
75
+ /**
76
+ * The rank of a measure played at <racine>: 2 in the frame's own repository (its workspaces hold
77
+ * the package @roomi-fields/grillhouse in development), 1 elsewhere.
78
+ * @param {string} racine the root of the repository the measure plays in
79
+ * @returns {1 | 2}
80
+ */
81
+ export function rangDeLaMesure(racine) {
82
+ return paquets(racine).some(p => p.pkg.name === CADRE) ? 2 : 1;
83
+ }
84
+
85
+ const RANGS = { 1: 'mesure de premier rang', 2: 'mesure de second rang (le dépôt du cadre)' };
86
+
49
87
  /** The longest wait for the lock of the machine, in ms, unless GRILLHOUSE_ATTENTE_MACHINE_MS names another. */
50
88
  export const ATTENTE_MACHINE_MS = 60 * 60_000;
51
89
  // The gap between two lines that say the wait goes on.
@@ -90,12 +128,9 @@ const heure = ms => new Date(ms).toTimeString().slice(0, 8);
90
128
  const duree = ms =>
91
129
  ms < 120_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`;
92
130
 
93
- // Who holds the lock <verrou>: its process, that process's working directory and command when
94
- // they read (on one line, cut at 200 characters), and since when the lock stands (« le processus 12 (dans /d) : node x, depuis
95
- // 10:04:31 (3 min) »).
96
- function qui(verrou) {
97
- const pid = detenteur(verrou);
98
- if (pid === null) return 'une autre mesure';
131
+ // The process <pid>, its working directory and command when they read (on one line, cut at 200
132
+ // characters): « le processus 12 (dans /d) : node x ».
133
+ function processus(pid) {
99
134
  let ou = '';
100
135
  try {
101
136
  ou = ` (dans ${readlinkSync(`/proc/${pid}/cwd`)})`;
@@ -110,6 +145,14 @@ function qui(verrou) {
110
145
  const quoi = commande
111
146
  ? ` : ${commande.length > 200 ? `${commande.slice(0, 200)}…` : commande}`
112
147
  : '';
148
+ return `le processus ${pid}${ou}${quoi}`;
149
+ }
150
+
151
+ // Who holds the lock <verrou>: its process (`processus`) and since when the lock stands
152
+ // (« le processus 12 (dans /d) : node x, depuis 10:04:31 (3 min) »).
153
+ function qui(verrou) {
154
+ const pid = detenteur(verrou);
155
+ if (pid === null) return 'une autre mesure';
113
156
  let depuis = '';
114
157
  try {
115
158
  const t = statSync(verrou).mtimeMs;
@@ -117,9 +160,58 @@ function qui(verrou) {
117
160
  } catch {
118
161
  // the lock is gone meanwhile
119
162
  }
120
- return `le processus ${pid}${ou}${quoi}${depuis}`;
163
+ return `${processus(pid)}${depuis}`;
121
164
  }
122
165
 
166
+ // The directory where the measures of the first rank that wait for <verrou> name themselves.
167
+ const fileDuPremierRang = verrou => `${verrou}.premier-rang`;
168
+
169
+ // Names this process among the measures of the first rank that wait for <verrou>; a function that
170
+ // removes its name.
171
+ function attendreAuPremierRang(verrou) {
172
+ const dossier = fileDuPremierRang(verrou);
173
+ const f = path.join(dossier, `${process.pid}-${randomUUID()}`);
174
+ mkdirSync(dossier, { recursive: true });
175
+ writeFileSync(f, String(process.pid));
176
+ return () => rmSync(f, { force: true });
177
+ }
178
+
179
+ // The live processes of the measures of the first rank that wait for <verrou>; the name of a
180
+ // process that is gone is removed.
181
+ function premiersQuiAttendent(verrou) {
182
+ const dossier = fileDuPremierRang(verrou);
183
+ let noms;
184
+ try {
185
+ noms = readdirSync(dossier);
186
+ } catch {
187
+ return [];
188
+ }
189
+ const pids = [];
190
+ for (const nom of noms) {
191
+ const pid = Number(/^(\d+)-/.exec(nom)?.[1]);
192
+ if (pid && vivant(pid)) pids.push(pid);
193
+ else rmSync(path.join(dossier, nom), { force: true });
194
+ }
195
+ return pids;
196
+ }
197
+
198
+ // Whom a measure of the rank <rang> waits for at <verrou>: the holder of the lock, and for the
199
+ // second rank the measures of the first that wait too, which pass first.
200
+ function attendus(verrou, rang) {
201
+ const tenu = detenteur(verrou) === null ? 'libre' : `tenu par ${qui(verrou)}`;
202
+ const premiers = rang === 2 ? premiersQuiAttendent(verrou) : [];
203
+ const avant = !premiers.length
204
+ ? ''
205
+ : premiers.length === 1
206
+ ? ` ; une mesure de premier rang attend aussi et passe d'abord : ${processus(premiers[0])}`
207
+ : ` ; ${premiers.length} mesures de premier rang attendent aussi et passent d'abord : ${premiers.map(processus).join(' ; ')}`;
208
+ return `le verrou de la mesure lourde (${verrou}) est ${tenu}${avant}`;
209
+ }
210
+
211
+ // Whether a measure of the rank <rang> may take the lock <verrou>: the second rank only while no
212
+ // measure of the first waits.
213
+ const peutPrendre = (verrou, rang) => rang === 1 || premiersQuiAttendent(verrou).length === 0;
214
+
123
215
  // The parent of the process <pid>, read in /proc (the field after the state, which follows the
124
216
  // command, itself free to hold spaces and parentheses), or null.
125
217
  function parent(pid) {
@@ -140,10 +232,20 @@ function borneDe(env) {
140
232
  return env[VARIABLE_ATTENTE] && Number.isFinite(n) && n >= 0 ? n : ATTENTE_MACHINE_MS;
141
233
  }
142
234
 
143
- // Waits for the lock <verrou>, saying so (`dire`) when it begins and every <rappelMs>; returns the
144
- // time waited, in ms. Throws an AttenteDeLaMachine at once when an ancestor holds the lock, and at
145
- // the bound <borneMs>.
146
- function attendreLaMachine(verrou, { dire, borneMs, rappelMs }) {
235
+ // Waits for the lock <verrou> at the rank <rang>, saying so (`dire`) when it begins and every
236
+ // <rappelMs>; returns the time waited, in ms. Throws an AttenteDeLaMachine at once when an ancestor
237
+ // holds the lock, and at the bound <borneMs>. A measure of the first rank names itself among those
238
+ // that wait while it waits.
239
+ function attendreLaMachine(verrou, { rang, dire, borneMs, rappelMs }) {
240
+ const retirer = rang === 1 ? attendreAuPremierRang(verrou) : () => {};
241
+ try {
242
+ return attendreAuRang(verrou, { rang, dire, borneMs, rappelMs });
243
+ } finally {
244
+ retirer();
245
+ }
246
+ }
247
+
248
+ function attendreAuRang(verrou, { rang, dire, borneMs, rappelMs }) {
147
249
  const debut = Date.now();
148
250
  let dit = null;
149
251
  for (;;) {
@@ -156,19 +258,20 @@ function attendreLaMachine(verrou, { dire, borneMs, rappelMs }) {
156
258
  const t = Date.now();
157
259
  if (t - debut >= borneMs)
158
260
  throw new AttenteDeLaMachine(
159
- `⏱ attente du verrou de la machine bornée à ${duree(borneMs)} (${VARIABLE_ATTENTE}) : le verrou (${verrou}) est encore tenu par ${qui(verrou)} ; à repasser (code ${A_REPASSER}).`,
261
+ `⏱ attente du verrou de la machine bornée à ${duree(borneMs)} (${VARIABLE_ATTENTE}), ${RANGS[rang]} : ${attendus(verrou, rang)} ; à repasser (code ${A_REPASSER}).`,
160
262
  A_REPASSER
161
263
  );
162
264
  if (dit === null)
163
265
  dire(
164
- `⏳ la machine mesure déjà : le verrou de la mesure lourde (${verrou}) est tenu par ${qui(verrou)} ; attente de sa fin, ${duree(borneMs)} au plus.`
266
+ `⏳ ${RANGS[rang]}, la machine mesure déjà : ${attendus(verrou, rang)} ; attente de son tour, ${duree(borneMs)} au plus.`
165
267
  );
166
268
  else if (t - dit >= rappelMs)
167
269
  dire(
168
- `⏳ attente du verrou de la machine depuis ${duree(t - debut)} : il est tenu par ${qui(verrou)}.`
270
+ `⏳ ${RANGS[rang]}, attente du verrou de la machine depuis ${duree(t - debut)} : ${attendus(verrou, rang)}.`
169
271
  );
170
272
  if (dit === null || t - dit >= rappelMs) dit = t;
171
- if (prendreVerrou(verrou, { attendre: false })) return Date.now() - debut;
273
+ if (peutPrendre(verrou, rang) && prendreVerrou(verrou, { attendre: false }))
274
+ return Date.now() - debut;
172
275
  attendreUnPeu(Math.max(1, Math.min(200, borneMs - (Date.now() - debut))));
173
276
  }
174
277
  }
@@ -177,8 +280,10 @@ function attendreLaMachine(verrou, { dire, borneMs, rappelMs }) {
177
280
  * Runs <mesure> under the lock of the machine and returns what it returns. <mesure> receives the
178
281
  * environment of its children, which carries the lock to them.
179
282
  * @param {(envEnfant: Record<string, string | undefined>) => T} mesure a synchronous measure
180
- * @param {{ env?: Record<string, string | undefined>, dire?: (ligne: string) => void,
181
- * borneMs?: number, rappelMs?: number }} options `borneMs`, the bound of the wait
283
+ * @param {{ env?: Record<string, string | undefined>, racine?: string,
284
+ * dire?: (ligne: string) => void, borneMs?: number, rappelMs?: number }} options `racine`, the
285
+ * root of the repository the measure plays in, which gives its rank (`rangDeLaMesure`; the
286
+ * working directory by default); `borneMs`, the bound of the wait
182
287
  * (GRILLHOUSE_ATTENTE_MACHINE_MS, else ATTENTE_MACHINE_MS); `rappelMs`, the gap between two
183
288
  * lines that say the wait goes on (30 s)
184
289
  * @returns {T}
@@ -190,17 +295,19 @@ export function avecLaMachine(
190
295
  mesure,
191
296
  {
192
297
  env = process.env,
298
+ racine = process.cwd(),
193
299
  dire = l => process.stdout.write(`${l}\n`),
194
300
  borneMs = borneDe(env),
195
301
  rappelMs = RAPPEL_MS,
196
302
  } = {}
197
303
  ) {
198
- if (composantDeLEnveloppe(env)) return mesure(env);
304
+ if (paquetsDeLEnveloppe(env)) return mesure(env);
199
305
  const verrou = verrouDeMachine(env);
200
306
  if (env[VARIABLE_TENUE] && lire(verrou) === env[VARIABLE_TENUE]) return mesure(env);
201
307
  mkdirSync(path.dirname(verrou), { recursive: true });
202
- if (!prendreVerrou(verrou, { attendre: false })) {
203
- const attendu = attendreLaMachine(verrou, { dire, borneMs, rappelMs });
308
+ const rang = rangDeLaMesure(racine);
309
+ if (!(peutPrendre(verrou, rang) && prendreVerrou(verrou, { attendre: false }))) {
310
+ const attendu = attendreLaMachine(verrou, { rang, dire, borneMs, rappelMs });
204
311
  dire(`✓ verrou de la machine pris après ${Math.round(attendu / 1000)} s.`);
205
312
  }
206
313
  try {
@@ -11,12 +11,27 @@
11
11
  // - developpeur writes none of the testwriter's files, and no other file until its ticket's
12
12
  // description holds a "## Architecture" section. The ticket is the "TON TICKET : <id>" line of the agent's
13
13
  // launch prompt, read from its transcript.
14
+ // - In an envelope (`GRILLHOUSE_ENVELOPPE`, the packages of the plan, scripts/enveloppe/
15
+ // paquets-du-plan.mjs), a write in the copy out of the packages the plan writes (`ecritsDuPlan`)
16
+ // and out of the shared test material, whose rule is the one above, stops the session, as the
17
+ // visibility of Bazel refuses a target the rule does not open: the hook answers Claude Code's
18
+ // `continue: false`, the tool refused (`permissionDecision: deny`), its reason naming the file's
19
+ // package and the plan, and leaves that reason in the session's working directory
20
+ // (`MARQUE_HORS_PLAN` under TMPDIR), which the launcher hands to the circuit. The mount of the
21
+ // envelope refuses the same write to any tool; the hook says why, and stops.
14
22
  import { execFileSync } from 'node:child_process';
15
- import { existsSync, readFileSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
24
  import { tmpdir } from 'node:os';
17
25
  import path from 'node:path';
18
26
  import { pathToFileURL } from 'node:url';
19
27
  import { matieres, NOM_DE_TEST } from '../consommateurs.mjs';
28
+ import {
29
+ ecritsDuPlan,
30
+ MARQUE_HORS_PLAN,
31
+ paquetQuiTient,
32
+ paquetsDeLEnveloppe,
33
+ } from '../enveloppe/paquets-du-plan.mjs';
34
+ import { paquets } from '../paquets.mjs';
20
35
 
21
36
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
22
37
 
@@ -68,6 +83,31 @@ function racineDe(file) {
68
83
  }
69
84
  }
70
85
 
86
+ // The stop of a write out of the plan of the envelope <env>, `{ paquet, raison }`, or null: a
87
+ // write in the repository holding the file, out of the packages the plan writes and out of the
88
+ // shared test material.
89
+ function horsDuPlan(input, { tmp = tmpdir(), env = process.env } = {}) {
90
+ const plan = paquetsDeLEnveloppe(env);
91
+ if (!plan || !WRITE_TOOLS.has(input.tool_name)) return null;
92
+ const raw = input.tool_input?.file_path ?? input.tool_input?.notebook_path;
93
+ if (!raw) return null;
94
+ const file = path.resolve(input.cwd ?? process.cwd(), raw);
95
+ if (isScratch(file, tmp)) return null;
96
+ const copie = racineDe(file);
97
+ if (!copie) return null;
98
+ const liste = paquets(copie);
99
+ const ecrits = ecritsDuPlan(plan, liste);
100
+ const rel = path.relative(copie, file);
101
+ if (ecrits.includes('.') || ecrits.some(d => rel.startsWith(`${d}/`))) return null;
102
+ if (matieres(copie).some(m => isInside(file, m.dir))) return null;
103
+ const tenant = paquetQuiTient(rel, liste);
104
+ const paquet = tenant === '.' ? 'la racine du dépôt' : tenant;
105
+ return {
106
+ paquet,
107
+ raison: `⛔ hors-plan — ${rel} est dans ${paquet}, hors des paquets du plan (${plan.join(', ')}) : ta séance s'arrête là, et le superviseur étend le plan ou refuse l'écriture.`,
108
+ };
109
+ }
110
+
71
111
  // The ticket id of the launch prompt. A sub-agent's prompt opens its own transcript, next to the
72
112
  // session's; an agent launched as its own session (`claude --agent`) has it in the session's.
73
113
  function ticketOf(input) {
@@ -157,9 +197,30 @@ export function refusal(role, input, { tmp = tmpdir(), describe = ticketDescript
157
197
 
158
198
  if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
159
199
  const input = JSON.parse(readFileSync(0, 'utf8'));
160
- const reason = refusal(process.argv[2], input);
161
- if (reason) {
162
- process.stderr.write(reason + '\n');
163
- process.exitCode = 2;
200
+ const stop = horsDuPlan(input);
201
+ if (stop) {
202
+ // The mark for the circuit; the stop below is the hook's answer, mark or not.
203
+ try {
204
+ writeFileSync(path.join(tmpdir(), MARQUE_HORS_PLAN), stop.raison);
205
+ } catch {
206
+ // A working directory gone leaves no mark; the session still stops.
207
+ }
208
+ process.stdout.write(
209
+ `${JSON.stringify({
210
+ continue: false,
211
+ stopReason: stop.raison,
212
+ hookSpecificOutput: {
213
+ hookEventName: 'PreToolUse',
214
+ permissionDecision: 'deny',
215
+ permissionDecisionReason: stop.raison,
216
+ },
217
+ })}\n`
218
+ );
219
+ } else {
220
+ const reason = refusal(process.argv[2], input);
221
+ if (reason) {
222
+ process.stderr.write(reason + '\n');
223
+ process.exitCode = 2;
224
+ }
164
225
  }
165
226
  }
@@ -5,26 +5,26 @@ description: "Le développeur : travailler un ticket du dépôt courant en agent
5
5
 
6
6
  # Développeur — travailler un ticket
7
7
 
8
- Tu travailles un seul ticket, dans la copie qui porte son numéro. Le superviseur (`pitmaster`) te
9
- l'a confié ; le responsable du projet l'a validé. La charte du projet, déjà dans ton contexte,
10
- prime sur tout le reste.
8
+ Tu travailles un seul ticket, dans la copie qui porte son numéro : une seule séance porte tout son
9
+ code, dans tous les paquets de son plan. Le superviseur (`pitmaster`) te l'a confié ; le
10
+ responsable du projet l'a validé. La charte du projet, déjà dans ton contexte, prime sur tout le
11
+ reste.
11
12
 
12
13
  ## Au démarrage
13
14
 
14
15
  1. Ta consigne porte ton ticket, lu au lancement de ta séance : sa description et ses critères,
15
- ses notes, la dernière passation de ton rôle sur ton composant (celle du testwriter, pour ta
16
- première séance). Tu ne le relis pas par bd, qui le refuse à ta séance. Puis
17
- `bd update <id> --claim`. Des notes au ticket disent qu'un agent s'est arrêté avant toi : tu
18
- reprends au point qu'elles nomment, sur l'état de ta copie (`git -C <copie> status`,
19
- `git -C <copie> diff`).
20
- 2. Lis les documents des composants que ton ticket touche : `CADRE.md`, `INTERFACE.md`,
21
- `ARCHITECTURE.md` (`docs/`, ou `packages/<x>/docs/`), et le lexique `CONTEXT.md`. Ainsi ton
22
- code part de l'architecture écrite. Un document absent ne t'arrête pas.
23
- 3. Lis la section « Ce que les composants d'avant publient » de ta consigne : pour chaque
24
- fournisseur, sa ligne, les noms déclarés pour ton composant et l'adresse de sa rubrique « Rend ».
25
- Le reste se lit à deux renvois : tous les éléments avec leur phrase dans
16
+ ses notes, la dernière passation de ton rôle (celle du testwriter, pour ta première séance), les
17
+ paquets de son plan et les constats du relecteur qui restent ouverts pour toi. Tu ne le relis
18
+ pas par bd, qui le refuse à ta séance. Puis `bd update <id> --claim`. Des notes au ticket
19
+ disent qu'un agent s'est arrêté avant toi : tu reprends au point qu'elles nomment, sur l'état de
20
+ ta copie (`git -C <copie> status`, `git -C <copie> diff`).
21
+ 2. Lis les documents des paquets du plan : `CADRE.md`, `INTERFACE.md`, `ARCHITECTURE.md`
22
+ (`docs/`, ou `packages/<x>/docs/`), et le lexique `CONTEXT.md`. Ainsi ton code part de
23
+ l'architecture écrite. Un document absent ne t'arrête pas.
24
+ 3. Lis ce que publient les fournisseurs des paquets du plan : leur ligne et leurs éléments dans
26
25
  `docs/agents/index-des-interfaces.md`, puis la section d'un élément dans l'interface du
27
- fournisseur. Ainsi tu sais ce qui est déjà calculé, et où.
26
+ fournisseur, et la ligne « Déclaré pour » de sa section « Consommateurs ». Ainsi tu sais ce qui
27
+ est déjà calculé, et où.
28
28
  4. Lance les tests du testwriter, déjà dans ta copie (`npm test -- <fichiers>`, chemins depuis la
29
29
  racine du dépôt), et vois-les rouges ; sans test neuf, ce sont les rouges existants que le
30
30
  rapport de la preuve nomme.
@@ -47,16 +47,20 @@ prime sur tout le reste.
47
47
  - **Tu nommes la règle que tu appliques** : dans le ticket, la règle du cadre (`R…` du `CADRE.md`)
48
48
  et la règle d'architecture ou de spécification. Quand aucune ne s'applique, la décision manque
49
49
  et remonte au superviseur.
50
- - **Ton plan porte ta part de la livraison, sur ton seul composant** : il va au ticket avant le
51
- code. Un plan qui demande plus t'arrête : tu rends ce découpage au superviseur. Ainsi un ticket
52
- reste petit, et tu écris dans ton seul paquet.
50
+ - **Ton plan porte la livraison, sur les seuls paquets du plan** : il va au ticket avant le code,
51
+ le fournisseur d'abord. Un plan qui demande un autre paquet t'arrête : ta fiche le nomme, et le
52
+ superviseur étend le plan ou refuse. Ainsi un ticket reste dans son plan.
53
53
 
54
54
  ## Le code
55
55
 
56
- - **Ton composant reçoit les faits de ses fournisseurs** : ceux de la section « Ce que les
57
- composants d'avant publient ». Tu lis chacun à son adresse, et ton code le prend tel que son
58
- fournisseur le publie ; un calcul qui refait l'un d'eux se refuse. Ainsi chaque fait a un seul
59
- calcul, comme une passe LLVM reçoit son analyse du gestionnaire.
56
+ - **Chaque paquet reçoit les faits de ses fournisseurs** : ceux que publie leur interface, déclarés
57
+ pour lui. Tu lis chacun à son adresse, et ton code le prend tel que son fournisseur le publie ; un
58
+ calcul qui refait l'un d'eux se refuse. Ainsi chaque fait a un seul calcul, comme une passe LLVM
59
+ reçoit son analyse du gestionnaire.
60
+ - **L'index d'abord** : toute recherche commence par `rtfm_search` (mode `hybrid`) pour le quoi, et
61
+ `rtfm_expand` lit en entier un résultat qu'elle rend ; l'appel se cherche par
62
+ `codegraph explore <symboles>`, ou l'outil `codegraph_explore` ; le shell lit un fichier déjà
63
+ nommé. Ainsi ton code part de ce que le dépôt contient.
60
64
  - **L'existant d'abord** : avant d'écrire un calcul ou d'inventer un nom, tu cherches celui qui
61
65
  existe (ton paquet, le lexique, les interfaces voisines) et tu vérifies ce qu'il fait.
62
66
  - **Une entrée ambiguë se refuse** : ce que le produit ne sait pas interpréter d'une seule façon
@@ -68,12 +72,13 @@ prime sur tout le reste.
68
72
  - **Le comportement tient dans les critères** : ton code fait ce que disent les critères
69
73
  d'acceptation, et rien d'autre.
70
74
 
71
- Tu vois ton paquet en entier, et des autres composants leur interface et leur forme publiée. Tu
72
- écris ton seul paquet : le reste de la copie est en lecture seule, et une écriture hors de lui
73
- échoue (« Read-only file system ») ; un dossier hors des paquets (des données comme `libraries/`)
74
- s'écrit par le composant qui le possède, que ta `raison` nomme. Tes
75
- voisins arrivent construits, et les traces que lisent tes tests produites : `npm test -- <fichiers>` construit ton seul paquet et régénère ses propres traces, et `npm run api`,
76
- après lui, écrit le rapport d'API et la part générée de ton seul composant. La matière de test
75
+ Tu lis toute la copie, index compris. Tu écris les seuls paquets du plan de ton ticket (les
76
+ paquets de son titre) : le reste de la copie est en lecture seule, une écriture hors d'eux
77
+ échoue (« Read-only file system »), et ton verrou arrête ta séance sur une écriture hors du plan,
78
+ que le superviseur étend ou refuse ; un dossier hors des paquets (des données comme `libraries/`)
79
+ s'écrit par le paquet qui le possède, que ta `raison` nomme. Les paquets hors du plan
80
+ arrivent construits, et les traces que lisent tes tests produites : `npm test -- <fichiers>` construit les paquets du plan et régénère leurs propres traces, et `npm run api`,
81
+ après lui, écrit le rapport d'API et la part générée des paquets du plan. La matière de test
77
82
  partagée, en lecture seule pour toi, arrive construite : `npm test -- <un test de la matière>` la
78
83
  joue sans la construire, et les journaux et le cache du lancement restent dans ta séance. Les
79
84
  gardes du cadre (frontières, consommateurs, index des interfaces, rapports d'API) tournent à côté
@@ -98,7 +103,7 @@ avec ta fiche. Ainsi le circuit reprend dès ta fiche rendue.
98
103
  recommandation ; ton code suit ensuite le verdict « Arbitrage » au ticket.
99
104
  - **Un fait que ton code prend chez un fournisseur hors de sa ligne « Déclaré pour »** (un élément
100
105
  publié pas encore déclaré pour toi, ou un fait que ta section ne porte pas) : une découverte qui
101
- bloque, que le superviseur porte au référent technique, et qui entre ensuite dans la section
106
+ bloque, que le superviseur tranche ou porte au responsable, et qui entre ensuite dans la section
102
107
  « Consommateurs » du fournisseur.
103
108
  - **Une donnée qui manque chez un fournisseur** : tu t'arrêtes, et ta fiche finit sur BLOQUÉ, sa
104
109
  `raison` nommant la donnée et le fournisseur ; tu ne la calcules pas dans ton paquet. Le superviseur ajoute le fournisseur
@@ -117,45 +122,50 @@ son adresse) : le circuit la consigne fermée sous ton ticket, et tu continues.
117
122
  ## Vérifier et livrer
118
123
 
119
124
  - **Le circuit contrôle après toi** : ta séance finie, le circuit du ticket joue les tests du lot
120
- et de chaque paquet des composants jusqu'au tien, puis les gardes dans ton enveloppe ; après le
121
- dernier composant, la passe finale, la passe complète que joue l'intégration. Un rouge te
122
- relance avec sa sortie quand il est dans ton composant. Tu es lancé sur un rouge (de la preuve, un `rouge existant` compris, ou des
123
- contrôles), ou directement dans un composant qui ne porte aucun test (aucun paquet avec un
124
- script `test:vitest`), sans testwriter ni preuve : une séance qui ne change aucun fichier de ton
125
- composant arrête le circuit (`sans-livraison`), et ta passation dit pourquoi. Tes propres
126
- lancements pendant le travail restent les tiens. Ainsi ton lot arrive au relecteur vert.
127
- - **Un rouge qui reste dans les tests va à leur testwriter** : quand le rouge qui reste est dans les
128
- tests d'un composant du titre, le tien ou un composant d'avant (un test existant que ton
129
- changement de type casse), ta fiche porte `renvoi` : le rôle testwriter et ce composant, écrit
130
- comme le titre le nomme. Le circuit rejoue ce composant depuis son testwriter, ta `raison` et ta
131
- passation pour rapport, puis les composants d'après qu'il n'a pas encore menés et la passe
132
- finale, sans te relancer sur ce rouge ; dans la reprise d'un `RENDU`, les constats de ton
133
- composant et des composants d'après restent, chacun rejoué ensuite ; le même renvoi deux fois de suite, sans contrôles verts
134
- entre eux, l'arrête (`sans-livraison`). Ainsi le rouge va à l'agent qui écrit les tests.
125
+ et de chaque paquet du plan, puis les gardes dans l'enveloppe du plan ; puis la passe finale, la
126
+ passe complète que joue l'intégration. Un rouge te relance avec sa sortie quand il est dans un
127
+ paquet du plan ; hors du plan, il attend la passe finale, où il monte au superviseur. Tu es lancé
128
+ sur un rouge (de la preuve, un `rouge existant` compris, ou des contrôles), sur un constat du
129
+ relecteur ouvert pour toi, ou directement sur un plan qui ne porte aucun test (aucun paquet avec
130
+ un script `test:vitest`), sans testwriter ni preuve : une séance qui ne change aucun fichier du
131
+ plan n'a rien à livrer, et ses contrôles rouges arrêtent le circuit (`sans-livraison`) ; ta
132
+ passation dit pourquoi. Tes propres lancements pendant le travail restent les tiens. Ainsi ton
133
+ lot arrive au relecteur vert.
134
+ - **Un rouge qui reste dans les tests va au testwriter** : quand le rouge qui reste est dans les
135
+ tests du lot (un test existant que ton changement de type casse), ta fiche porte `renvoi`, le
136
+ rôle testwriter. Le circuit relance le testwriter, ta `raison` et ta passation pour rapport, puis
137
+ la preuve et ta séance, sans te relancer sur ce rouge ; le même renvoi deux fois de suite, sans
138
+ contrôles verts entre eux, l'arrête (`sans-livraison`). Ainsi le rouge va à l'agent qui écrit
139
+ les tests.
135
140
  - **Ta séance finit sur ta fiche**, que ton schéma décrit et que le circuit lit seule :
136
141
  - `statut` FAIT : ton code est écrit, et les contrôles le jugent ;
137
142
  - `statut` BLOQUÉ, avec sa `raison` : ce qui reste est hors de ta portée (un test, la matière
138
- de test partagée, un fichier hors de ton enveloppe, une décision hors de ton rôle) ; avec
143
+ de test partagée, un fichier hors du plan, une décision hors de ton rôle) ; avec
139
144
  `renvoi`, c'est le renvoi au testwriter ; sans lui, le circuit s'arrête (`hors-portee`) ;
140
145
  - `statut` BESOIN, avec sa `question` : une réponse te manque pour avancer ; le circuit
141
146
  s'arrête (`besoin`) ;
142
147
  - `ecarts` : ce que le message de commit nomme, chacun sous son `sujet` (ce qui s'écarte :
143
148
  une référence, un test, un plafond), avec son `texte` ;
144
149
  - `decouvertes` : ce que le circuit consigne fermé sous le ticket.
145
- Sur un arrêt, le régulateur tranche, et le circuit ne te relance pas sur ce rouge. Ainsi
150
+ Sur un arrêt, ta question monte au superviseur, et le circuit ne te relance pas sur ce rouge. Ainsi
146
151
  tu ne tournes jamais sur un rouge que tu as dit hors de ta portée.
152
+ - **Un fait repris porte sa pièce** : un fait que tu reprends d'un autre agent (une passation, un
153
+ rapport, un constat) se relit à sa pièce avant que tu t'appuies dessus, et ta raison ou ta
154
+ question porte la sienne, `piece` (fichier:ligne depuis la racine de la copie, ou « $ commande »
155
+ et sa sortie), que le circuit cherche dans la copie. Ainsi une ligne mal lue s'arrête à toi.
147
156
  - **Une relance reprend ta séance** : après des contrôles rouges, le circuit reprend ta séance avec
148
- une consigne courte, le rapport neuf seul ; ta consigne, ta compétence et ce que tu as lu restent
157
+ une consigne courte, le rapport neuf ; ta consigne, ta compétence et ce que tu as lu restent
149
158
  valables. La dernière tentative, sur le modèle fort, est une séance neuve et entière. Ainsi tu
150
- repars de ce que tu sais.
159
+ repars de ce que tu sais. Toute relance (des contrôles rouges, un `RENDU`, une reprise du
160
+ superviseur) porte les constats du relecteur qui restent ouverts pour toi, tous, jusqu'à la
161
+ relecture qui les juge levés. Ainsi aucun constat ne se perd d'une relance à l'autre.
151
162
  - **Les tests ciblés** : les tests touchés et voisins, plus ceux des composants en aval quand une
152
163
  interface change (`npm test -- <fichiers>`).
153
164
  - **Une suite se juge sur tous ses échecs** : leur liste se compare nom par nom aux échecs connus.
154
165
  Ainsi « vert » veut dire que rien de neuf n'a cassé.
155
166
  - **Un écart dit l'état que le lot laisse au commit** : un écart voulu, une référence changée, un
156
167
  test mis en quarantaine avec le ticket qui le referme entrent dans `ecarts` de ta fiche, avec leur
157
- cause ; un test qu'un composant d'après du ticket referme se dit dans ta passation, car la passe
158
- finale est verte avant le commit. Un sujet qu'une fiche d'avant nomme se reprend tel quel, et ta
168
+ cause ; la passe finale est verte avant le commit. Un sujet qu'une fiche d'avant nomme se reprend tel quel, et ta
159
169
  valeur remplace la sienne : le circuit écrit chaque sujet une fois, de la dernière fiche qui le
160
170
  porte. Ainsi le message de commit ne se répète ni ne se contredit.
161
171
  - **Tu retires ce que tu crées pour mesurer**, chemin par chemin, avant de rendre la main.