@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,628 @@
1
+ #!/usr/bin/env node
2
+ // The envelope of a role agent: runs a command (the agent's `claude` session) inside a bwrap
3
+ // sandbox that shows the agent its own component, and of every other component only what it
4
+ // publishes. `npx grillhouse enveloppe <copie> <composant> <rôle> -- <commande…>`
5
+ //
6
+ // - The root `/` is read-only. Only these are written: the agent's copy (for a component that is a
7
+ // package, that package alone, below); a `/tmp` empty and
8
+ // proper to the session, where only the scratchpad directory of the copy comes back, the
9
+ // session's working directory inside it (`dossierDeTravail`; Claude Code's scratchpads root,
10
+ // `CLAUDE_CODE_TMPDIR` else the launch's temporary directory, so that under a pass it lies in
11
+ // the base of the pass and goes with it); the npm
12
+ // cache; the Claude session state (`~/.claude` and the copy's project directory), whose
13
+ // configuration, skills, plugins and other projects stay read-only. `~/.claude.json` is a
14
+ // throwaway copy: the session writes it, the real file does not change. The tickets base (the
15
+ // main tree's `.beads/`) is written: the agent claims, plans, hands over and files what it
16
+ // finds. Every `.git`, the
17
+ // copy's included, stays read-only: the agent reads the git state and delivers a patch
18
+ // (`git diff`, and `git diff --no-index /dev/null <file>` for a new file); it never commits.
19
+ // The cache of Turborepo (`<main tree>/.turbo/cache`, shared by every copy) is written: the
20
+ // agent's runs of `npm test` keep their tasks there, as every copy's runs do.
21
+ // - The home and the session directories (`$XDG_RUNTIME_DIR`, `/run/user/<uid>`) show only what
22
+ // the envelope declares: each starts empty and sealed read-only, and the home gets back
23
+ // `~/.claude`, `~/.claude.json`, the npm cache, the paths of `MAISON_EN_LECTURE` (claude, node,
24
+ // git and the session's tools) and the repository. No socket of the session comes back. Of
25
+ // the machine's credentials, only claude's own are in sight.
26
+ // - The session gets only the environment the envelope declares (`environnementDeLaSeance`), as
27
+ // sudo's env_reset and bwrap's --clearenv give it: the base of a login session, claude's own
28
+ // configuration and credentials (`ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`), the author of its
29
+ // comments at the ticket (`BEADS_ACTOR`, which bd reads), the npm cache it
30
+ // mounts, `npm_config_ignore_scripts=true`, and `TMPDIR`, the session's working directory, where
31
+ // `mktemp` and `os.tmpdir()` write. With it npm runs no lifecycle script (`npm install` leaves the
32
+ // `prepare` that writes under the read-only `.git`; the launcher installs outside the session),
33
+ // while `npm test` and `npm run <script>` still run their script, without its `pre` or `post`
34
+ // (npm's `ignore-scripts`, docs/content/using-npm/config.md); no script of the frame has one. bwrap
35
+ // starts with that environment alone, so no other variable of the machine (`GH_TOKEN`,
36
+ // `NPM_TOKEN`…) enters, by name or by argument. The session also gets `GRILLHOUSE_ENVELOPPE`, the
37
+ // component's path (`composant.mjs`): the frame's commands build and write that component only. A declared variable that names a path
38
+ // (`VARIABLES_DE_CHEMIN`) enters only when its path is in sight inside the envelope, tested there
39
+ // before the command runs. The session has its own process space: it reads the environment of
40
+ // no process of the machine.
41
+ // - No index of the repository is in sight: neither codegraph nor rtfm comes back in the home,
42
+ // and every index directory (`INDEX`) of every worktree, the copy's included, is an empty
43
+ // directory sealed read-only; the worktree around it keeps its mount, the copy in writing.
44
+ // - A declared path that is a symbolic link comes back as that link, and its target with it
45
+ // (`avecLiens`).
46
+ // - A worktree of the repository under an emptied directory (the home, `/tmp`) comes back
47
+ // read-only, `.git` included; the copy and the tickets base are then written as above.
48
+ // - An envelope on the main tree is refused, and so is a component that does not exist; when it is
49
+ // the name of a package of the copy, the refusal gives its path.
50
+ // - <copie> is the agent's git worktree; <composant> is the component's path inside it
51
+ // (`packages/a`, or `src/parser` in a one-package project); <rôle> is the agent's role
52
+ // (`testwriter`, `developpeur`…).
53
+ // - In every worktree of the repository, the component's parent directory (`packages/`, `src/`)
54
+ // is emptied. In <copie>, the component is mounted back in writing; every sibling component
55
+ // gets back, read-only, its published parts: `package.json`, `docs/INTERFACE.md`, `dist/`, its
56
+ // package configuration of Turborepo (`turbo.json`, `turbo.jsonc`), by which Turborepo reads in
57
+ // the envelope the task graph it reads outside, the paths that the `files` field of its
58
+ // `package.json` names, and the directory each trace it declares writes (`grillhouse.traces`,
59
+ // which the launcher produces before the session), when they exist inside it.
60
+ // The parent's own files (`src/index.ts`) come back read-only, and the emptied directories are
61
+ // then sealed read-only.
62
+ // - What the component's tests declare they read comes back read-only, as Bazel gives a test the
63
+ // `data` it declares: each path of `grillhouse.lit` of its `package.json`, and of the `lit` of
64
+ // each trace it declares (`cheminsLus`, each path from the root of <copie>), that lies in a
65
+ // sibling component and exists, a directory or a file. A path the envelope already shows (the
66
+ // root, the component itself, the shared test material, whose mount the role decides) adds no
67
+ // mount.
68
+ // - For a component that is a package of the copy (`paquets` of `scripts/paquets.mjs`), the agent
69
+ // writes that package and nothing else of the copy, as a sandbox of Bazel or of Nix opens in
70
+ // writing only what the action declares: the copy is read-only, the folders of its root that no
71
+ // package holds (`libraries/`, `spec/`) included, and a write there fails on « Read-only file
72
+ // system », which the agent sees. Such a folder is written by the component that owns it, never
73
+ // by a right the envelope adds: what the tests declare they read stays read-only. The package
74
+ // of the shared test material, for the `testwriter`, the session's files and the mounts outside
75
+ // the copy keep their rights; where a run of Turborepo writes at the root of the copy
76
+ // (`sortiesDeLaRacine`, its `.turbo`) is an empty directory proper to the session, created on
77
+ // disk before it.
78
+ // - A component that is no package (a folder of the root, `scripts`; `src/parser` in a one-package
79
+ // project) writes the package that holds it, the root: the copy stays written. A folder of the
80
+ // root hides nothing: its scripts work on the whole repository. The rest holds: the root
81
+ // read-only, the copy written, its `.git` read-only.
82
+ // - The package of the shared test material is every component of <copie> whose `package.json`
83
+ // exports `./test-fixtures` (`matieres` of `scripts/consommateurs.mjs`). It comes back whole, after the agent's own component:
84
+ // written for the `testwriter`, read-only for every other role, its own component included. For
85
+ // such a role, where a run of its tests writes in it (`sortiesDeTest`: Turborepo's task logs,
86
+ // Vite's cache) is an empty directory proper to the session, as Bazel writes a test's logs out
87
+ // of its source: what is written there leaves no trace. The envelope creates each of them on
88
+ // disk, empty, before the session, so that bwrap mounts over it inside the read-only package.
89
+ // - The traversal guard refuses to launch when a symbolic link inside the component, or inside a
90
+ // package of the shared test material, points into a hidden directory: such a link would cross
91
+ // the envelope.
92
+ // - The exit codes are named in `codes.mjs`.
93
+ import { execFileSync, spawnSync } from 'node:child_process';
94
+ import {
95
+ copyFileSync,
96
+ existsSync,
97
+ mkdirSync,
98
+ lstatSync,
99
+ readdirSync,
100
+ readFileSync,
101
+ readlinkSync,
102
+ statSync,
103
+ } from 'node:fs';
104
+ import os from 'node:os';
105
+ import path from 'node:path';
106
+ import { pathToFileURL } from 'node:url';
107
+ import { matieres as lesMatieres } from '../consommateurs.mjs';
108
+ import { paquets as lesPaquets } from '../paquets.mjs';
109
+ import { CODE_REFUS, CODE_USAGE } from './codes.mjs';
110
+ import { VARIABLE } from './composant.mjs';
111
+ import { ouvrirBase } from '../vitest-config/base-de-passe.mjs';
112
+
113
+ const PUBLISHED = ['package.json', 'docs/INTERFACE.md', 'dist', 'turbo.json', 'turbo.jsonc'];
114
+ // The role that writes the package of the shared test material.
115
+ const ROLE_MATIERE = 'testwriter';
116
+ // What a run of a package's tests writes in that package, relative to it: the logs of its tasks,
117
+ // which Turborepo writes on every run, a task replayed from its cache included
118
+ // (`.turbo/turbo-<task>.log`), and no option of Turborepo sends elsewhere; the cache of Vite, where
119
+ // Vitest writes the results of the run (`node_modules/.vite/vitest/`), and whose failure fails it.
120
+ const SORTIES_DE_TEST = ['.turbo', 'node_modules/.vite'];
121
+ // The directories of <dir> where a run of its tests writes.
122
+ const sortiesDeTest = dir => SORTIES_DE_TEST.map(s => path.join(dir, s));
123
+ // What a run of Turborepo writes at the root of the repository, relative to it: its run summaries
124
+ // and the state of its daemon (`.turbo`), its cache living in the main tree (`magasins`).
125
+ const SORTIES_DE_LA_RACINE = ['.turbo'];
126
+ // The directories of the copy <copie> where a run of Turborepo writes.
127
+ const sortiesDeLaRacine = copie => SORTIES_DE_LA_RACINE.map(s => path.join(copie, s));
128
+ // True when <composant> of <copie> is a package of the copy (`paquets`, the directory of each
129
+ // relative to it): its envelope then writes that package alone.
130
+ const estUnPaquet = (copie, composant, paquets) =>
131
+ paquets.some(p => path.resolve(copie, p.dossier) === path.resolve(copie, composant));
132
+ // The directories where codegraph and rtfm keep the index of a tree they index: the source of
133
+ // every component, which the envelope hides.
134
+ const INDEX = ['.codegraph', '.rtfm'];
135
+ // The machine's temporary directory: an empty one, proper to the session, replaces it.
136
+ const TEMPORAIRE = '/tmp';
137
+ // What stays read-only inside `~/.claude`: the configuration and what it loads.
138
+ const CLAUDE_EN_LECTURE = [
139
+ 'settings.json',
140
+ 'settings.local.json',
141
+ 'CLAUDE.md',
142
+ 'keybindings.json',
143
+ 'agents',
144
+ 'commands',
145
+ 'hooks',
146
+ 'plugins',
147
+ 'skills',
148
+ 'projects',
149
+ ];
150
+ // What the home shows read-only besides `~/.claude`, as paths relative to it: claude's launcher
151
+ // and versions, node and its global tools (bd), the state of bd and of its dolt database, and the
152
+ // git configuration. The home shows nothing else: neither codegraph nor rtfm, whose index is for
153
+ // the roles that see the whole repository.
154
+ const MAISON_EN_LECTURE = [
155
+ '.local/bin/claude',
156
+ '.local/share/claude',
157
+ '.nvm',
158
+ '.config/bd',
159
+ '.beads',
160
+ '.dolt',
161
+ '.gitconfig',
162
+ '.config/git/config',
163
+ '.config/git/ignore',
164
+ '.config/git/attributes',
165
+ ];
166
+
167
+ // The variables of a login session that the envelope passes on, as sudo's env_reset keeps them,
168
+ // claude's OAuth credential, the author of the session's comments at the ticket (`BEADS_ACTOR`,
169
+ // which the circuit sets to the session's role), and the prefixes of the families it passes on:
170
+ // the locale, and the configuration and credentials of claude's API. The state of a launching claude session
171
+ // (`CLAUDE_CODE_SESSION_ID`, its messaging socket and token…) stays out.
172
+ const ENVIRONNEMENT_DECLARE = [
173
+ 'HOME',
174
+ 'PATH',
175
+ 'USER',
176
+ 'LOGNAME',
177
+ 'SHELL',
178
+ 'LANG',
179
+ 'LANGUAGE',
180
+ 'TZ',
181
+ 'TERM',
182
+ 'COLORTERM',
183
+ 'CLAUDE_CODE_OAUTH_TOKEN',
184
+ 'BEADS_ACTOR',
185
+ ];
186
+ const FAMILLES_DECLAREES = ['LC_', 'ANTHROPIC_'];
187
+ // The declared variables that name a path: each enters only when its path is in sight inside the
188
+ // envelope, so a tool never reads a path the envelope hides.
189
+ const VARIABLES_DE_CHEMIN = [
190
+ 'NODE_EXTRA_CA_CERTS',
191
+ 'SSL_CERT_FILE',
192
+ 'SSL_CERT_DIR',
193
+ 'NVM_DIR',
194
+ 'XDG_RUNTIME_DIR',
195
+ ];
196
+ // Run inside the envelope before the command: unsets each named variable whose path does not
197
+ // exist there, then runs the command. `sh -c EPREUVE sh <variables…> -- <commande…>`.
198
+ const EPREUVE =
199
+ 'while [ "$1" != -- ]; do eval "p=\\${$1-}"; ' +
200
+ 'if [ -n "$p" ] && [ ! -e "$p" ]; then unset "$1"; fi; shift; done; shift; exec "$@"';
201
+
202
+ // The directory of the scratchpads of <copie> under the environment <env> of the launch: Claude
203
+ // Code keeps them under `<temporary>/claude-<uid>/<working directory in dashes>/`, every sign
204
+ // outside [A-Za-z0-9] a dash, <temporary> its `CLAUDE_CODE_TMPDIR`, else the system's temporary
205
+ // directory as `os.tmpdir()` reads it (TMPDIR, TMP, TEMP, else /tmp): under a pass, the base of
206
+ // the pass (scripts/vitest-config/base-de-passe.mjs), removed with it.
207
+ function scratchpadsDe(copie, env = process.env) {
208
+ const enTirets = path.resolve(copie).replace(/[^A-Za-z0-9]/g, '-');
209
+ const temporaire = env.CLAUDE_CODE_TMPDIR || env.TMPDIR || env.TMP || env.TEMP || TEMPORAIRE;
210
+ return path.join(path.resolve(temporaire), `claude-${process.getuid()}`, enTirets);
211
+ }
212
+
213
+ // The working directory of a session in <copie> under the environment <env> of the launch:
214
+ // `travail` in the copy's scratchpads, which the envelope mounts in writing. The envelope creates
215
+ // it and names it in `TMPDIR`; the launcher names it in the instruction.
216
+ export function dossierDeTravail(copie, env = process.env) {
217
+ return path.join(scratchpadsDe(copie, env), 'travail');
218
+ }
219
+
220
+ // The environment of the session, from the environment <env> of the launch: the declared
221
+ // variables and families that <env> holds, `npm_config_cache` set to the npm cache that the
222
+ // envelope mounts in writing, `npm_config_ignore_scripts` set to `true` (npm runs no lifecycle
223
+ // script: a `prepare` that writes under the read-only `.git` would fail `npm install`; the
224
+ // launcher installs outside the session), `TMPDIR` set to the session's working directory
225
+ // <travail>, and `GRILLHOUSE_ENVELOPPE` set to <composant>. Nothing else of <env> enters.
226
+ function environnementDeLaSeance(env, { cacheNpm, composant, travail }) {
227
+ const declare = nom =>
228
+ ENVIRONNEMENT_DECLARE.includes(nom) ||
229
+ VARIABLES_DE_CHEMIN.includes(nom) ||
230
+ FAMILLES_DECLAREES.some(f => nom.startsWith(f));
231
+ const garde = Object.entries(env).filter(([nom, v]) => v !== undefined && declare(nom));
232
+ return {
233
+ ...Object.fromEntries(garde),
234
+ npm_config_cache: cacheNpm,
235
+ npm_config_ignore_scripts: 'true',
236
+ TMPDIR: travail,
237
+ [VARIABLE]: composant,
238
+ };
239
+ }
240
+
241
+ // The command run inside the envelope: <commande>, behind the test of the variables that name a
242
+ // path.
243
+ function commandeEprouvee(commande) {
244
+ return ['/bin/sh', '-c', EPREUVE, 'sh', ...VARIABLES_DE_CHEMIN, '--', ...commande];
245
+ }
246
+
247
+ // The mounts outside the copy, in bwrap's order: an empty `/tmp`; the home that holds
248
+ // `~/.claude` and the session directories, empty and sealed; the copy's scratchpads, the npm
249
+ // cache and `~/.claude` in writing; the throwaway `~/.claude.json`; over them, what the session
250
+ // reads without writing; the copy's project directory, in writing; last, the declared paths of
251
+ // the home, read-only.
252
+ export function montagesDeLaSeance({
253
+ cacheNpm,
254
+ claude,
255
+ projet,
256
+ etatJetable,
257
+ scratchpads,
258
+ sessions = [],
259
+ }) {
260
+ const maison = path.dirname(claude);
261
+ return [
262
+ { chemin: TEMPORAIRE, vide: true },
263
+ ...[maison, ...sessions].map(chemin => ({ chemin, vide: true, scelle: true })),
264
+ ...[scratchpads, cacheNpm, claude].map(chemin => ({ chemin, ecriture: true })),
265
+ { chemin: `${claude}.json`, source: etatJetable, ecriture: true },
266
+ ...CLAUDE_EN_LECTURE.map(nom => ({ chemin: path.join(claude, nom), ecriture: false })),
267
+ { chemin: projet, ecriture: true },
268
+ ...MAISON_EN_LECTURE.map(rel => ({ chemin: path.join(maison, rel), ecriture: false })),
269
+ ];
270
+ }
271
+
272
+ // The mounts with every declared path that is a symbolic link on disk turned into that same link,
273
+ // followed by what it leads to, as Flatpak exposes a link: link by link, each target that lies in
274
+ // an emptied directory and under no other declared path comes back with the link's rights, so a
275
+ // launcher finds its package beside its real path. A target the root shows needs no mount.
276
+ // `lien(path)` gives the text of the link at <path>, or null when <path> is not a link.
277
+ function avecLiens(montages, lien) {
278
+ const videes = montages.filter(m => m.vide).map(m => m.chemin);
279
+ const declares = montages.filter(m => !m.vide && !m.lien).map(m => m.chemin);
280
+ return montages.flatMap(m => {
281
+ if (m.vide || m.lien || (m.source ?? m.chemin) !== m.chemin) return [m];
282
+ const suite = [];
283
+ const vus = new Set();
284
+ let chemin = m.chemin;
285
+ let texte = lien(chemin);
286
+ if (texte === null) return [m];
287
+ while (texte !== null && !vus.has(chemin)) {
288
+ vus.add(chemin);
289
+ suite.push({ chemin, lien: texte });
290
+ chemin = path.resolve(path.dirname(chemin), texte);
291
+ const cachee = videes.some(v => inside(chemin, v));
292
+ const tenue = declares.some(d => d !== m.chemin && inside(chemin, d));
293
+ if (!cachee || tenue) return suite;
294
+ texte = lien(chemin);
295
+ }
296
+ return vus.has(chemin) ? suite : [...suite, { chemin, ecriture: m.ecriture }];
297
+ });
298
+ }
299
+
300
+ const lienSurDisque = p => {
301
+ try {
302
+ return lstatSync(p).isSymbolicLink() ? readlinkSync(p) : null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ };
307
+
308
+ // The worktrees of the repository that holds <copie>, main tree first.
309
+ function worktrees(copie) {
310
+ const out = execFileSync('git', ['-C', copie, 'worktree', 'list', '--porcelain'], {
311
+ encoding: 'utf8',
312
+ });
313
+ return out
314
+ .split('\n')
315
+ .filter(l => l.startsWith('worktree '))
316
+ .map(l => l.slice('worktree '.length));
317
+ }
318
+
319
+ // What a directory of the real disk holds, as { name, dir } entries.
320
+ function lister(dir) {
321
+ return readdirSync(dir, { withFileTypes: true }).map(e => ({
322
+ name: e.name,
323
+ dir: e.isDirectory(),
324
+ }));
325
+ }
326
+
327
+ // The symbolic links under <dir> whose resolved target lies outside <dir>.
328
+ function liensSortants(dir) {
329
+ const found = [];
330
+ const walk = d => {
331
+ for (const e of readdirSync(d, { withFileTypes: true })) {
332
+ const p = path.join(d, e.name);
333
+ if (e.isSymbolicLink()) {
334
+ const target = path.resolve(d, readlinkSync(p));
335
+ const rel = path.relative(dir, target);
336
+ if (rel.startsWith('..') || path.isAbsolute(rel)) found.push({ lien: p, cible: target });
337
+ } else if (e.isDirectory() && e.name !== 'node_modules') {
338
+ walk(p);
339
+ }
340
+ }
341
+ };
342
+ walk(dir);
343
+ return found;
344
+ }
345
+
346
+ // The paths, relative to <dir>, a package of <copie>, that its `package.json` publishes beyond the
347
+ // parts every package publishes: what its `files` field names, and the directory each trace it
348
+ // declares writes (`grillhouse.traces`, its `ecrit` from the root of <copie>); none without them.
349
+ function fichiersPublies(dir, copie) {
350
+ const manifeste = path.join(dir, 'package.json');
351
+ if (!existsSync(manifeste)) return [];
352
+ const { files, grillhouse } = JSON.parse(readFileSync(manifeste, 'utf8'));
353
+ const traces = Object.values(grillhouse?.traces ?? {})
354
+ .filter(t => typeof t?.ecrit === 'string')
355
+ .map(t => path.relative(dir, path.join(copie, t.ecrit)));
356
+ return [...(Array.isArray(files) ? files : []), ...traces];
357
+ }
358
+
359
+ /**
360
+ * The paths that the tests of the package at <dir> declare they read: its `grillhouse.lit`, then
361
+ * the `lit` of each trace it declares (`grillhouse.traces`), each path from the root; none without
362
+ * a package.json or a declaration.
363
+ * @param {string} dir the directory of the package
364
+ * @returns {string[]}
365
+ */
366
+ export function cheminsLus(dir) {
367
+ const manifeste = path.join(dir, 'package.json');
368
+ if (!existsSync(manifeste)) return [];
369
+ const { grillhouse } = JSON.parse(readFileSync(manifeste, 'utf8'));
370
+ const liste = v => (Array.isArray(v) ? v.filter(l => typeof l === 'string') : []);
371
+ return [
372
+ ...liste(grillhouse?.lit),
373
+ ...Object.values(grillhouse?.traces ?? {}).flatMap(t => liste(t?.lit)),
374
+ ];
375
+ }
376
+
377
+ const inside = (child, parent) => {
378
+ const rel = path.relative(parent, child);
379
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
380
+ };
381
+
382
+ // The refusal of <composant> in the repository at <depot>, or null when it is the path of a
383
+ // directory there (`.`, the whole repository, included). A <composant> that is the name of a
384
+ // package of `paquets` (`paquets` of `scripts/paquets.mjs`), or the name of its folder, is told
385
+ // its path, as git says « did you mean ». `fs` gives `existe(path)`. The launcher judges the
386
+ // component with it before preparing the copy, in the ticket's copy when it exists, else in the
387
+ // main tree or in the tree of the ticket's branch, its `existe` accepting either; `plan` judges it
388
+ // in the copy.
389
+ export function jugerComposant({ depot, composant, paquets = [] }, fs) {
390
+ const propre = path.join(depot, composant);
391
+ if (fs.existe(propre)) return null;
392
+ const nomme =
393
+ paquets.find(p => p.pkg?.name === composant) ??
394
+ paquets.find(p => path.basename(p.dossier) === composant);
395
+ return nomme
396
+ ? `\`${composant}\` n'est pas le chemin d'un composant ; écris \`${nomme.dossier}\`.`
397
+ : `Le composant ${propre} n'existe pas.`;
398
+ }
399
+
400
+ // The bwrap arguments for this agent, or the refusal that stops its launch. `montages` lists, in
401
+ // order, the paths outside the copy that are emptied, written, read back or made a symbolic link
402
+ // (`lien`, its text) (`montagesDeLaSeance`); an emptied one marked `scelle` is sealed read-only
403
+ // once every mount inside it is made.
404
+ // `matieres` lists the packages of the shared test material, relative to the copy
405
+ // (`matieres` of `scripts/consommateurs.mjs`); `role` is the agent's role, and only the `testwriter` writes them.
406
+ // `magasins` lists the task caches written by every run, the agent's included (Turborepo's
407
+ // `<main tree>/.turbo/cache`); one that does not exist is not mounted.
408
+ // `paquets` lists the packages of the copy, which `jugerComposant` reads to judge <composant>;
409
+ // a <composant> among them writes itself alone of the copy.
410
+ // `fs` gives `lister(dir)`, `existe(path)`, `liens(dir)`, `publies(dir, copie)` and `lus(dir)`
411
+ // (what the tests of the package at <dir> declare they read, `cheminsLus`), so the plan is testable
412
+ // without disk.
413
+ export function plan(
414
+ { trees, copie, composant, role, matieres = [], montages = [], magasins = [], paquets = [] },
415
+ fs
416
+ ) {
417
+ // The worktrees start with the main tree: an envelope on it would let the agent write its root.
418
+ if (trees.length > 0 && path.resolve(trees[0]) === path.resolve(copie)) {
419
+ return {
420
+ refus: `La copie ${copie} est l'arbre principal : un agent travaille dans sa propre copie.`,
421
+ };
422
+ }
423
+ const parentRel = path.dirname(composant);
424
+ const nom = path.basename(composant);
425
+ // A folder of the root (`scripts`) works on the whole repository: no neighbour to hide.
426
+ const racine = parentRel === '.' || parentRel === '';
427
+ const propre = path.join(copie, composant);
428
+ const refusDuComposant = jugerComposant({ depot: copie, composant, paquets }, fs);
429
+ if (refusDuComposant) return { refus: refusDuComposant };
430
+
431
+ const caches = racine ? [] : trees.map(t => path.join(t, parentRel));
432
+ const matiere = matieres.map(m => path.join(copie, m));
433
+ for (const { lien, cible } of [propre, ...matiere].flatMap(d => fs.liens(d))) {
434
+ if (caches.some(c => inside(cible, c))) {
435
+ return {
436
+ refus: `Le lien ${lien} vise ${cible}, que l'enveloppe masque : il traverserait l'enveloppe.`,
437
+ };
438
+ }
439
+ }
440
+
441
+ // /dev/shm, which /dev brings in writing, becomes an empty directory proper to the session.
442
+ const args = [
443
+ '--ro-bind',
444
+ '/',
445
+ '/',
446
+ '--dev-bind',
447
+ '/dev',
448
+ '/dev',
449
+ '--tmpfs',
450
+ '/dev/shm',
451
+ '--unshare-pid',
452
+ '--proc',
453
+ '/proc',
454
+ ];
455
+ for (const { chemin, source = chemin, ecriture, vide, lien } of montages) {
456
+ if (vide) args.push('--tmpfs', chemin);
457
+ else if (lien !== undefined) args.push('--symlink', lien, chemin);
458
+ else if (fs.existe(source)) args.push(ecriture ? '--bind' : '--ro-bind', source, chemin);
459
+ }
460
+ // A worktree that an emptied directory covers comes back read-only, before the copy is written.
461
+ const videes = montages.filter(m => m.vide).map(m => m.chemin);
462
+ for (const t of trees) {
463
+ if (videes.some(v => inside(t, v)) && fs.existe(t)) args.push('--ro-bind', t, t);
464
+ }
465
+ // The copy is mounted after them: an emptied /tmp does not hide it. Its .git stays read-only.
466
+ // For a component that is a package, the copy is read-only and its package alone comes back in
467
+ // writing below, as a sandbox of Bazel opens only what the action declares; where a run of
468
+ // Turborepo writes at the root is an empty directory proper to the session.
469
+ const paquet = !racine && estUnPaquet(copie, composant, paquets);
470
+ args.push(paquet ? '--ro-bind' : '--bind', copie, copie);
471
+ if (paquet) for (const s of sortiesDeLaRacine(copie)) if (fs.existe(s)) args.push('--tmpfs', s);
472
+ const gitDeLaCopie = path.join(copie, '.git');
473
+ if (fs.existe(gitDeLaCopie)) args.push('--ro-bind', gitDeLaCopie, gitDeLaCopie);
474
+ // The task cache (Turborepo's) is written by the agent's runs, as by every copy's.
475
+ for (const m of magasins) if (fs.existe(m)) args.push('--bind', m, m);
476
+ const tickets = trees.length > 0 ? path.join(trees[0], '.beads') : null;
477
+ if (tickets && fs.existe(tickets)) args.push('--bind', tickets, tickets);
478
+ // Every index directory of every worktree, the copy's included, becomes an empty directory in
479
+ // its place; the worktree around it keeps its mount.
480
+ const index = [...new Set([...trees, copie])]
481
+ .flatMap(t => INDEX.map(i => path.join(t, i)))
482
+ .filter(i => fs.existe(i));
483
+ for (const i of index) args.push('--tmpfs', i);
484
+ for (const c of caches) if (fs.existe(c)) args.push('--tmpfs', c);
485
+ const parent = path.join(copie, parentRel);
486
+ for (const e of racine ? [] : fs.lister(parent)) {
487
+ const p = path.join(parent, e.name);
488
+ if (!e.dir) {
489
+ args.push('--ro-bind', p, p);
490
+ } else if (e.name !== nom && !matiere.includes(p)) {
491
+ for (const part of new Set([...PUBLISHED, ...fs.publies(p, copie)])) {
492
+ const q = path.join(p, part);
493
+ if (inside(q, p) && fs.existe(q)) args.push('--ro-bind', q, q);
494
+ }
495
+ }
496
+ }
497
+ // What the component's tests declare they read, in a sibling the envelope hides.
498
+ if (!racine) {
499
+ for (const l of new Set(fs.lus?.(propre) ?? [])) {
500
+ const q = path.join(copie, l);
501
+ const visible = inside(q, propre) || matiere.some(m => inside(q, m));
502
+ if (inside(q, parent) && q !== parent && !visible && fs.existe(q)) {
503
+ args.push('--ro-bind', q, q);
504
+ }
505
+ }
506
+ }
507
+ args.push('--bind', propre, propre);
508
+ for (const m of matiere) {
509
+ args.push(role === ROLE_MATIERE ? '--bind' : '--ro-bind', m, m);
510
+ // What a run of its tests writes goes, for a role that does not write it, to an empty
511
+ // directory proper to the session, as Bazel writes a test's logs out of its source.
512
+ if (role === ROLE_MATIERE) continue;
513
+ for (const s of sortiesDeTest(m)) if (fs.existe(s)) args.push('--tmpfs', s);
514
+ }
515
+ // The emptied directories close behind the mounts: nothing new is written there.
516
+ for (const c of caches) if (fs.existe(c)) args.push('--remount-ro', c);
517
+ for (const { chemin } of montages.filter(m => m.scelle)) args.push('--remount-ro', chemin);
518
+ for (const i of index) args.push('--remount-ro', i);
519
+ args.push('--chdir', copie);
520
+ return { args };
521
+ }
522
+
523
+ const disque = {
524
+ lister,
525
+ existe: existsSync,
526
+ liens: d => (existsSync(d) && statSync(d).isDirectory() ? liensSortants(d) : []),
527
+ publies: fichiersPublies,
528
+ lus: cheminsLus,
529
+ };
530
+
531
+ // The command: the session of <commande> in the envelope of <composant> in <copie>; its exit code.
532
+ function envelopper(argv) {
533
+ const sep = argv.indexOf('--');
534
+ const [copieArg, composant, role] = argv.slice(0, sep < 0 ? undefined : sep);
535
+ const commande = sep < 0 ? [] : argv.slice(sep + 1);
536
+ if (!copieArg || !composant || !role || commande.length === 0) {
537
+ process.stderr.write('usage: enveloppe.mjs <copie> <composant> <rôle> -- <commande…>\n');
538
+ return CODE_USAGE;
539
+ }
540
+ const copie = path.resolve(copieArg);
541
+ // A launch from a package of a repository of workspaces sets npm's `workspace`, which
542
+ // `npm config` refuses: the cache is the user's, whatever the workspace.
543
+ const cacheNpm = execFileSync('npm', ['config', 'get', 'cache', '--workspaces=false'], {
544
+ encoding: 'utf8',
545
+ }).trim();
546
+ const claude = path.join(os.homedir(), '.claude');
547
+ // A session's project directory carries its working directory, every sign outside
548
+ // [A-Za-z0-9] turned into a dash; it is created here so that bwrap mounts it in writing.
549
+ const enTirets = copie.replace(/[^A-Za-z0-9]/g, '-');
550
+ const projet = path.join(claude, 'projects', enTirets);
551
+ mkdirSync(projet, { recursive: true });
552
+ const scratchpads = scratchpadsDe(copie);
553
+ // The session's working directory, created here inside the scratchpads that bwrap mounts.
554
+ const travail = dossierDeTravail(copie);
555
+ mkdirSync(travail, { recursive: true });
556
+ // `~/.claude.json` holds the permissions and servers of every project: it enters the session
557
+ // as a throwaway copy, in a base of its own removed on exit and swept when the envelope is
558
+ // killed (../vitest-config/base-de-passe.mjs).
559
+ const temporaire = ouvrirBase('enveloppe');
560
+ const etatJetable = path.join(temporaire.dossier, 'claude.json');
561
+ if (existsSync(`${claude}.json`)) copyFileSync(`${claude}.json`, etatJetable);
562
+ // The session directories of this user that exist: the XDG one and the systemd one.
563
+ const sessions = [
564
+ ...new Set(
565
+ [process.env.XDG_RUNTIME_DIR, `/run/user/${process.getuid()}`]
566
+ .filter(d => d && existsSync(d))
567
+ .map(d => path.resolve(d))
568
+ ),
569
+ ];
570
+ const montages = avecLiens(
571
+ montagesDeLaSeance({ cacheNpm, claude, projet, etatJetable, scratchpads, sessions }),
572
+ lienSurDisque
573
+ );
574
+ const matieres = lesMatieres(copie).map(m => path.relative(copie, m.dir));
575
+ // For a role that does not write it, the directories where a run of the material's tests writes
576
+ // are created here, so that bwrap mounts an empty one over each inside the read-only package.
577
+ if (role !== ROLE_MATIERE) {
578
+ for (const m of matieres) {
579
+ for (const s of sortiesDeTest(path.join(copie, m))) mkdirSync(s, { recursive: true });
580
+ }
581
+ }
582
+ // For a component that is a package, where a run of Turborepo writes at the root of the copy is
583
+ // created here, so that bwrap mounts an empty one over it inside the read-only copy.
584
+ const paquets = lesPaquets(copie);
585
+ if (estUnPaquet(copie, composant, paquets)) {
586
+ for (const s of sortiesDeLaRacine(copie)) mkdirSync(s, { recursive: true });
587
+ }
588
+ // The cache of Turborepo, in the main tree (the folder of git's common directory), created here
589
+ // so that bwrap mounts it in writing.
590
+ const commun = execFileSync(
591
+ 'git',
592
+ ['-C', copie, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
593
+ { encoding: 'utf8' }
594
+ ).trim();
595
+ const magasins = [path.join(path.dirname(commun), '.turbo', 'cache')];
596
+ for (const m of magasins) mkdirSync(m, { recursive: true });
597
+ const { args, refus } = plan(
598
+ {
599
+ trees: worktrees(copie),
600
+ copie,
601
+ composant,
602
+ role,
603
+ matieres,
604
+ montages,
605
+ magasins,
606
+ paquets,
607
+ },
608
+ disque
609
+ );
610
+ let code;
611
+ if (refus) {
612
+ process.stderr.write(`⛔ ENVELOPPE REFUSÉE — ${refus}\n`);
613
+ code = CODE_REFUS;
614
+ } else {
615
+ const r = spawnSync('bwrap', [...args, ...commandeEprouvee(commande)], {
616
+ stdio: 'inherit',
617
+ env: environnementDeLaSeance(process.env, { cacheNpm, composant, travail }),
618
+ });
619
+ if (r.error) process.stderr.write(`⛔ ENVELOPPE IMPOSSIBLE — ${r.error.message}\n`);
620
+ code = r.error ? CODE_REFUS : (r.status ?? 1);
621
+ }
622
+ temporaire.fermer();
623
+ return code;
624
+ }
625
+
626
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
627
+ process.exitCode = envelopper(process.argv.slice(2));
628
+ }