@roomi-fields/grillhouse 0.0.0-stage → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/METACADRE.md +41 -0
  3. package/agents/developpeur.md +10 -0
  4. package/agents/explorateur.md +9 -0
  5. package/agents/questionneur.md +11 -0
  6. package/agents/referent-technique.md +9 -0
  7. package/agents/regulateur.md +11 -0
  8. package/agents/relecteur.md +9 -0
  9. package/agents/testwriter.md +10 -0
  10. package/bin/grillhouse.mjs +125 -0
  11. package/cadre/templates/projet/.claude/settings.json +30 -0
  12. package/cadre/templates/projet/.editorconfig +47 -0
  13. package/cadre/templates/projet/.env.example +57 -0
  14. package/cadre/templates/projet/.eslintrc.cjs +56 -0
  15. package/cadre/templates/projet/.github/workflows/ci.yml +66 -0
  16. package/cadre/templates/projet/.mcp.json +13 -0
  17. package/cadre/templates/projet/.prettierrc +11 -0
  18. package/cadre/templates/projet/CHANGELOG.md +61 -0
  19. package/cadre/templates/projet/CLAUDE.md +46 -0
  20. package/cadre/templates/projet/CONTRIBUTING.md +243 -0
  21. package/cadre/templates/projet/CREDITS.md +41 -0
  22. package/cadre/templates/projet/README.md +152 -0
  23. package/cadre/templates/projet/_gitignore +91 -0
  24. package/cadre/templates/projet/_npmignore +46 -0
  25. package/cadre/templates/projet/deployment/README.md +98 -0
  26. package/cadre/templates/projet/deployment/docs/01-INSTALL.md +145 -0
  27. package/cadre/templates/projet/deployment/docs/02-CONFIGURATION.md +117 -0
  28. package/cadre/templates/projet/deployment/docs/03-API.md +264 -0
  29. package/cadre/templates/projet/deployment/docs/04-TROUBLESHOOTING.md +262 -0
  30. package/cadre/templates/projet/deployment/scripts/install.ps1 +100 -0
  31. package/cadre/templates/projet/deployment/scripts/start.ps1 +61 -0
  32. package/cadre/templates/projet/deployment/scripts/stop.ps1 +37 -0
  33. package/cadre/templates/projet/deployment/scripts/test.ps1 +118 -0
  34. package/cadre/templates/projet/docs/agents/issue-tracker.md +44 -0
  35. package/cadre/templates/projet/ecosystem.config.cjs +73 -0
  36. package/cadre/templates/projet/package.json +90 -0
  37. package/cadre/templates/projet/pitmaster/SUIVI.md +22 -0
  38. package/cadre/templates/projet/src/config.ts +68 -0
  39. package/cadre/templates/projet/src/errors.ts +102 -0
  40. package/cadre/templates/projet/src/index.ts +46 -0
  41. package/cadre/templates/projet/src/services/.gitkeep +8 -0
  42. package/cadre/templates/projet/src/types/index.ts +24 -0
  43. package/cadre/templates/projet/src/utils/logger.ts +106 -0
  44. package/cadre/templates/projet/tests/integration/.gitkeep +7 -0
  45. package/cadre/templates/projet/tests/unit/config.spec.ts +68 -0
  46. package/cadre/templates/projet/tests/unit/errors.spec.ts +132 -0
  47. package/cadre/templates/projet/tests/unit/types.spec.ts +73 -0
  48. package/cadre/templates/projet/tsconfig.eslint.json +9 -0
  49. package/cadre/templates/projet/tsconfig.json +41 -0
  50. package/cadre/templates/projet/tsdoc.json +6 -0
  51. package/cadre/templates/projet/vitest.config.ts +44 -0
  52. package/cadre/templates/turbo/paquets.json +29 -0
  53. package/cadre/templates/turbo/sans-paquets.json +28 -0
  54. package/charte.md +107 -0
  55. package/docs/ARCHITECTURE.md +90 -0
  56. package/hooks/crochet.mjs +33 -0
  57. package/hooks/hooks.json +58 -0
  58. package/hooks/register.tsx +558 -0
  59. package/package.json +75 -4
  60. package/scripts/avancement.mjs +244 -0
  61. package/scripts/bilan.mjs +523 -0
  62. package/scripts/branche-de-nuit.mjs +59 -0
  63. package/scripts/cadre.mjs +109 -0
  64. package/scripts/circuit.mjs +4375 -0
  65. package/scripts/code-mort.mjs +371 -0
  66. package/scripts/composants-affectes.mjs +598 -0
  67. package/scripts/consommateurs-arbitres.mjs +192 -0
  68. package/scripts/consommateurs.mjs +310 -0
  69. package/scripts/crochet.mjs +24 -0
  70. package/scripts/crochets.mjs +135 -0
  71. package/scripts/dernier-vert.mjs +208 -0
  72. package/scripts/dette.mjs +181 -0
  73. package/scripts/droits.mjs +354 -0
  74. package/scripts/enveloppe/codes.mjs +17 -0
  75. package/scripts/enveloppe/composant.mjs +11 -0
  76. package/scripts/enveloppe/enveloppe.mjs +628 -0
  77. package/scripts/enveloppe/lancer.sh +311 -0
  78. package/scripts/etapes.mjs +323 -0
  79. package/scripts/etat-du-circuit.mjs +104 -0
  80. package/scripts/frontieres.mjs +456 -0
  81. package/scripts/gardes-du-cadre.mjs +72 -0
  82. package/scripts/greffons.mjs +338 -0
  83. package/scripts/grillhouse-maj.mjs +514 -0
  84. package/scripts/index-interfaces.mjs +293 -0
  85. package/scripts/init.mjs +117 -0
  86. package/scripts/integration/file-attente.mjs +61 -0
  87. package/scripts/integration/file.mjs +494 -0
  88. package/scripts/integration/integrer.mjs +381 -0
  89. package/scripts/integration/rejeu.mjs +139 -0
  90. package/scripts/integration/verdict.mjs +13 -0
  91. package/scripts/interfaces-contre-code.mjs +547 -0
  92. package/scripts/ligne-etat/defaut.sh +110 -0
  93. package/scripts/ligne-etat/ligne-etat.mjs +93 -0
  94. package/scripts/migrations/0.23.0-charte-importee.mjs +47 -0
  95. package/scripts/migrations/0.23.0-config-vitest.mjs +105 -0
  96. package/scripts/migrations/0.23.0-crochets-de-seance.mjs +55 -0
  97. package/scripts/migrations/0.23.0-marche-du-projet.mjs +82 -0
  98. package/scripts/migrations.mjs +43 -0
  99. package/scripts/nuit.sh +269 -0
  100. package/scripts/paquets.mjs +79 -0
  101. package/scripts/poussee-cles.mjs +46 -0
  102. package/scripts/preparation.mjs +84 -0
  103. package/scripts/references-par-les-types.mjs +420 -0
  104. package/scripts/registre.mjs +336 -0
  105. package/scripts/resultats.mjs +580 -0
  106. package/scripts/resumes.mjs +57 -0
  107. package/scripts/retirer-copie.sh +127 -0
  108. package/scripts/session-start.sh +203 -0
  109. package/scripts/sortie.mjs +155 -0
  110. package/scripts/sous-modules.sh +316 -0
  111. package/scripts/structure-guard.sh +27 -0
  112. package/scripts/superviseur.mjs +102 -0
  113. package/scripts/tableau.mjs +1886 -0
  114. package/scripts/tests.mjs +236 -0
  115. package/scripts/ticket.sh +23 -0
  116. package/scripts/turbo-des-paquets.mjs +899 -0
  117. package/scripts/un-commit-par-ticket.mjs +212 -0
  118. package/scripts/verifier.mjs +469 -0
  119. package/scripts/verrou-de-fichier.mjs +130 -0
  120. package/scripts/verrou-de-machine.mjs +211 -0
  121. package/scripts/verrous/commande.mjs +180 -0
  122. package/scripts/verrous/ticket-du-sous-agent.mjs +39 -0
  123. package/scripts/verrous/verrou.mjs +165 -0
  124. package/scripts/vitest-config/avancement.mjs +39 -0
  125. package/scripts/vitest-config/avancement.reporter.mjs +56 -0
  126. package/scripts/vitest-config/base-de-passe.mjs +128 -0
  127. package/scripts/vitest-config/base.d.ts +6 -0
  128. package/scripts/vitest-config/base.mjs +87 -0
  129. package/scripts/vitest-config/changements.mjs +173 -0
  130. package/scripts/vitest-config/cibles.mjs +207 -0
  131. package/scripts/vitest-config/duree.reporter.mjs +98 -0
  132. package/scripts/vitest-config/machine.setup.mjs +14 -0
  133. package/scripts/vitest-config/mesure.d.ts +6 -0
  134. package/scripts/vitest-config/mesure.mjs +13 -0
  135. package/scripts/vitest-config/mesure.setup.mjs +12 -0
  136. package/scripts/vitest-config/pression.mjs +90 -0
  137. package/scripts/vitest-config/resultats.mjs +23 -0
  138. package/scripts/vitest-config/resultats.setup.mjs +19 -0
  139. package/scripts/vitest.nuit.config.mjs +26 -0
  140. package/skills/developpeur/SKILL.md +175 -0
  141. package/skills/explorateur/SKILL.md +84 -0
  142. package/skills/grill/SKILL.md +238 -0
  143. package/skills/grill/references/releve-structurel.md +38 -0
  144. package/skills/mesure/SKILL.md +168 -0
  145. package/skills/pitmaster/SKILL.md +382 -0
  146. package/skills/pitmaster/references/cadre.md +119 -0
  147. package/skills/pitmaster/references/consigne-agent.md +308 -0
  148. package/skills/pitmaster/references/erreurs.md +25 -0
  149. package/skills/pitmaster/references/tour.md +124 -0
  150. package/skills/questionneur/SKILL.md +64 -0
  151. package/skills/redacteur/SKILL.md +84 -0
  152. package/skills/redacteur/references/documents-d-un-composant.md +105 -0
  153. package/skills/referent-technique/SKILL.md +145 -0
  154. package/skills/regulateur/SKILL.md +220 -0
  155. package/skills/release/SKILL.md +134 -0
  156. package/skills/release/scripts/update-version.cjs +92 -0
  157. package/skills/relecteur/SKILL.md +152 -0
  158. package/skills/relecteur/references/LICENSE-MIT +30 -0
  159. package/skills/relecteur/references/SOURCE.md +8 -0
  160. package/skills/relecteur/references/claims-check.md +14 -0
  161. package/skills/relecteur/references/deletion-check.md +14 -0
  162. package/skills/relecteur/references/edge-case-hunter.md +80 -0
  163. package/skills/relecteur/references/verification-gap.md +113 -0
  164. package/skills/testwriter/SKILL.md +187 -0
  165. package/types/index.d.ts +56 -0
  166. package/README.md +0 -3
@@ -0,0 +1,130 @@
1
+ // The lock of a file shared between processes: the file `<verrou>` stands while someone holds it.
2
+ // Taken by the integration (`.git/integration.lock`, `.git/file-integration.lock`) and by the
3
+ // register (`registre.jsonl.verrou`).
4
+ //
5
+ // - A lock is created whole and exclusively: its mark (the owner's process number, then a token
6
+ // unique to the taking) is written to a private file that is linked to `<verrou>`; the link
7
+ // fails while the lock stands. A lock is never seen empty.
8
+ // - Two judgements of an abandoned lock. By default the owner's process number: a lock whose
9
+ // process is gone is taken over. With `perime` (ms), the lock's age: a lock last written more
10
+ // than `perime` ms ago is taken over whatever process it names, for owners that do not share
11
+ // process numbers (a role agent runs under bwrap --unshare-pid; outside its envelope its
12
+ // number means nothing).
13
+ // - A takeover moves the lock aside by renaming it (one taker wins), judges what it moved, and
14
+ // deletes it only if it is the abandoned one; a fresh lock moved by mistake is put back.
15
+ // - A release removes the lock only when it still holds the mark of the taking being released.
16
+ // - The owner is the taking process by default; `pid` names another, for a lock that outlives its
17
+ // taker (the supervisor's, taken by the session start for the session's own process,
18
+ // scripts/superviseur.mjs).
19
+ import { randomUUID } from 'node:crypto';
20
+ import {
21
+ existsSync,
22
+ linkSync,
23
+ readFileSync,
24
+ renameSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
29
+ import { vivant } from './vitest-config/base-de-passe.mjs';
30
+
31
+ /** Blocks the process for `ms` milliseconds. */
32
+ export const attendreUnPeu = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
33
+
34
+ // The marks of the locks this process holds, by lock path.
35
+ const marques = new Map();
36
+
37
+ function lire(f) {
38
+ try {
39
+ return readFileSync(f, 'utf8').trim();
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ // Whether the lock file `f` is abandoned: by its age with `perime`, else by its owner's process.
46
+ function abandonne(f, perime) {
47
+ if (perime !== null) {
48
+ try {
49
+ return Date.now() - statSync(f).mtimeMs > perime;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+ const contenu = lire(f);
55
+ const pid = contenu === null ? null : /^(\d+)(?::|$)/.exec(contenu)?.[1];
56
+ return pid !== null && pid !== undefined && !vivant(Number(pid));
57
+ }
58
+
59
+ // Creates `verrou` holding `marque` in one step; false while it stands.
60
+ function creer(verrou, marque) {
61
+ const prive = `${verrou}.${marque.replace(':', '-')}`;
62
+ writeFileSync(prive, marque, { flag: 'wx' });
63
+ try {
64
+ linkSync(prive, verrou);
65
+ return true;
66
+ } catch (e) {
67
+ if (e.code !== 'EEXIST') throw e;
68
+ return false;
69
+ } finally {
70
+ rmSync(prive, { force: true });
71
+ }
72
+ }
73
+
74
+ // Removes the abandoned lock `verrou`. It is moved aside first, so only one taker moves it; what
75
+ // was moved is judged again, and a lock that turns out fresh is put back.
76
+ function reprendre(verrou, perime) {
77
+ const ecarte = `${verrou}.${process.pid}-${randomUUID()}.reprise`;
78
+ try {
79
+ renameSync(verrou, ecarte);
80
+ } catch {
81
+ return;
82
+ }
83
+ if (abandonne(ecarte, perime)) {
84
+ rmSync(ecarte, { force: true });
85
+ return;
86
+ }
87
+ try {
88
+ linkSync(ecarte, verrou);
89
+ } catch {
90
+ // another taker already holds a new lock: the one moved aside is lost with its owner's release
91
+ }
92
+ rmSync(ecarte, { force: true });
93
+ }
94
+
95
+ /**
96
+ * Takes the lock file `verrou`. Returns true once taken; with `attendre` false, returns false
97
+ * while another holds it.
98
+ * @param {string} verrou the lock file
99
+ * @param {{ attendre: boolean, perime?: number | null, pid?: number }} options `perime` (ms)
100
+ * judges an abandoned lock by its age instead of its owner's process; `pid` names the owner,
101
+ * the taking process by default
102
+ * @returns {boolean} whether the lock is taken
103
+ */
104
+ export function prendreVerrou(verrou, { attendre, perime = null, pid = process.pid }) {
105
+ const marque = `${pid}:${randomUUID()}`;
106
+ for (;;) {
107
+ if (creer(verrou, marque)) {
108
+ marques.set(verrou, marque);
109
+ return true;
110
+ }
111
+ if (abandonne(verrou, perime)) {
112
+ reprendre(verrou, perime);
113
+ continue;
114
+ }
115
+ if (!attendre) return false;
116
+ attendreUnPeu(perime === null ? 200 : 20);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Releases the lock this process took on `verrou`; a lock another holds now stays.
122
+ * @param {string} verrou the lock file
123
+ */
124
+ export function lacherVerrou(verrou) {
125
+ const marque = marques.get(verrou);
126
+ marques.delete(verrou);
127
+ if (marque !== undefined && existsSync(verrou) && lire(verrou) === marque) {
128
+ rmSync(verrou, { force: true });
129
+ }
130
+ }
@@ -0,0 +1,211 @@
1
+ // The lock of the machine for the heavy measure: one heavy measure at a time on the machine,
2
+ // whatever repository it plays in, as GNU make's job server shares its slots between every make of
3
+ // one build, and as Bazel holds a test that declares it wants the whole machine. Taken by every
4
+ // `npm run verifier` (scripts/verifier.mjs: the integration's, the queue's, the night's, a
5
+ // supervisor's) and by the heavy passes of the circuit of a ticket (scripts/circuit.mjs: the proof
6
+ // of the red and the checks), so that a measure gives the result of the code, not that of another
7
+ // measure beside it.
8
+ //
9
+ // - The lock is one file of the user's cache, shared by every repository of the machine:
10
+ // `${XDG_CACHE_HOME:-~/.cache}/grillhouse/mesure-lourde.lock`; GRILLHOUSE_VERROU_MACHINE names
11
+ // another file (the base configuration of the tests gives each test file its own,
12
+ // scripts/vitest-config/machine.setup.mjs). It is taken through scripts/verrou-de-fichier.mjs,
13
+ // judged by its owner's process: a lock whose process is gone is taken over.
14
+ // - A second measure waits for the end of the first, then plays once it holds the lock. It says
15
+ // that it waits and whom for (the process that holds the lock, its working directory, its
16
+ // command, since when) when it begins, then every 30 s, so that a log shows a pass is not
17
+ // frozen.
18
+ // - The wait is bounded, as `flock -w` and a database's lock timeout: ATTENTE_MACHINE_MS, 60 min,
19
+ // longer than the longest full pass measured (the frame's, 21 min; a final pass of BPScript
20
+ // 320.3, 35.5 min, a wait for rest of the CPU included); GRILLHOUSE_ATTENTE_MACHINE_MS names
21
+ // another bound, in ms. The bound reached, the measure stops at « à repasser » (75): another
22
+ // measure holds the machine, which is neither green nor red, and whoever launched the measure
23
+ // presents it again later. The stop throws an AttenteDeLaMachine, which names the holder.
24
+ // - A lock held by an ancestor of the waiting process (its parent, its parent's parent…, or the
25
+ // process itself) is a deadlock: the ancestor waits for its descendant, which waits for the
26
+ // lock. The wait says so at once and stops, red (1): the descendant lost the mark of the taking
27
+ // (an environment rebuilt, a test's), a defect to correct and not a load to wait out. It does
28
+ // not play under the ancestor's lock: make plays such a child at `-j1` because it cannot tell,
29
+ // but here the lost mark is the defect, and playing would hide it.
30
+ // - Who holds the lock passes it to its children, as make passes its job server in MAKEFLAGS: the
31
+ // variable GRILLHOUSE_MESURE_TENUE carries the mark of the taking, and a process whose variable
32
+ // names the mark the lock holds plays under it without taking it again (the final pass of the
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
36
+ // outside, and its home shows no cache.
37
+ // - 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';
39
+ import { homedir } from 'node:os';
40
+ import path from 'node:path';
41
+ import { composantDeLEnveloppe } from './enveloppe/composant.mjs';
42
+ import { attendreUnPeu, lacherVerrou, prendreVerrou } from './verrou-de-fichier.mjs';
43
+ import { A_REPASSER } from './vitest-config/pression.mjs';
44
+
45
+ const VARIABLE_VERROU = 'GRILLHOUSE_VERROU_MACHINE';
46
+ const VARIABLE_TENUE = 'GRILLHOUSE_MESURE_TENUE';
47
+ const VARIABLE_ATTENTE = 'GRILLHOUSE_ATTENTE_MACHINE_MS';
48
+
49
+ /** The longest wait for the lock of the machine, in ms, unless GRILLHOUSE_ATTENTE_MACHINE_MS names another. */
50
+ export const ATTENTE_MACHINE_MS = 60 * 60_000;
51
+ // The gap between two lines that say the wait goes on.
52
+ const RAPPEL_MS = 30_000;
53
+
54
+ /** The stop of a wait for the lock of the machine: `code` is 75 at the bound, 1 for a deadlock. */
55
+ export class AttenteDeLaMachine extends Error {
56
+ /**
57
+ * @param {string} message what stops the wait, the holder named
58
+ * @param {number} code the exit code the measure ends on
59
+ */
60
+ constructor(message, code) {
61
+ super(message);
62
+ this.name = 'AttenteDeLaMachine';
63
+ this.code = code;
64
+ }
65
+ }
66
+
67
+ // The lock file of the machine under <env>, beside which a measure out of a ticket names its
68
+ // progress (scripts/avancement.mjs).
69
+ export function verrouDeMachine(env = process.env) {
70
+ if (env[VARIABLE_VERROU]) return env[VARIABLE_VERROU];
71
+ const cache = env.XDG_CACHE_HOME || path.join(env.HOME || homedir(), '.cache');
72
+ return path.join(cache, 'grillhouse', 'mesure-lourde.lock');
73
+ }
74
+
75
+ const lire = f => {
76
+ try {
77
+ return readFileSync(f, 'utf8').trim();
78
+ } catch {
79
+ return null;
80
+ }
81
+ };
82
+
83
+ // The process number in the lock <verrou>, or null.
84
+ function detenteur(verrou) {
85
+ const pid = /^(\d+)/.exec(lire(verrou) ?? '')?.[1];
86
+ return pid ? Number(pid) : null;
87
+ }
88
+
89
+ const heure = ms => new Date(ms).toTimeString().slice(0, 8);
90
+ const duree = ms =>
91
+ ms < 120_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`;
92
+
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';
99
+ let ou = '';
100
+ try {
101
+ ou = ` (dans ${readlinkSync(`/proc/${pid}/cwd`)})`;
102
+ } catch {
103
+ // its directory does not read: the process alone is named
104
+ }
105
+ const commande = (lire(`/proc/${pid}/cmdline`) ?? '')
106
+ .split('\0')
107
+ .filter(Boolean)
108
+ .join(' ')
109
+ .replace(/\s+/g, ' ');
110
+ const quoi = commande
111
+ ? ` : ${commande.length > 200 ? `${commande.slice(0, 200)}…` : commande}`
112
+ : '';
113
+ let depuis = '';
114
+ try {
115
+ const t = statSync(verrou).mtimeMs;
116
+ depuis = `, depuis ${heure(t)} (${duree(Date.now() - t)})`;
117
+ } catch {
118
+ // the lock is gone meanwhile
119
+ }
120
+ return `le processus ${pid}${ou}${quoi}${depuis}`;
121
+ }
122
+
123
+ // The parent of the process <pid>, read in /proc (the field after the state, which follows the
124
+ // command, itself free to hold spaces and parentheses), or null.
125
+ function parent(pid) {
126
+ const stat = lire(`/proc/${pid}/stat`);
127
+ const ppid = stat ? Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]) : NaN;
128
+ return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
129
+ }
130
+
131
+ // Whether <pid> is this process or one of its ancestors.
132
+ function ancetre(pid) {
133
+ for (let p = process.pid; p !== null; p = parent(p)) if (p === pid) return true;
134
+ return false;
135
+ }
136
+
137
+ // The bound of the wait under <env>, in ms.
138
+ function borneDe(env) {
139
+ const n = Number(env[VARIABLE_ATTENTE]);
140
+ return env[VARIABLE_ATTENTE] && Number.isFinite(n) && n >= 0 ? n : ATTENTE_MACHINE_MS;
141
+ }
142
+
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 }) {
147
+ const debut = Date.now();
148
+ let dit = null;
149
+ for (;;) {
150
+ const pid = detenteur(verrou);
151
+ if (pid !== null && ancetre(pid))
152
+ throw new AttenteDeLaMachine(
153
+ `✗ blocage : le verrou de la mesure lourde (${verrou}) est tenu par un ancêtre de ce processus (${process.pid}), ${qui(verrou)} ; il attend la fin de ce processus, qui attendrait la sienne. L'environnement a perdu la marque de la prise (${VARIABLE_TENUE}) ; un essai nomme son propre verrou (${VARIABLE_VERROU}).`,
154
+ 1
155
+ );
156
+ const t = Date.now();
157
+ if (t - debut >= borneMs)
158
+ 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}).`,
160
+ A_REPASSER
161
+ );
162
+ if (dit === null)
163
+ 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.`
165
+ );
166
+ else if (t - dit >= rappelMs)
167
+ dire(
168
+ `⏳ attente du verrou de la machine depuis ${duree(t - debut)} : il est tenu par ${qui(verrou)}.`
169
+ );
170
+ if (dit === null || t - dit >= rappelMs) dit = t;
171
+ if (prendreVerrou(verrou, { attendre: false })) return Date.now() - debut;
172
+ attendreUnPeu(Math.max(1, Math.min(200, borneMs - (Date.now() - debut))));
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Runs <mesure> under the lock of the machine and returns what it returns. <mesure> receives the
178
+ * environment of its children, which carries the lock to them.
179
+ * @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
182
+ * (GRILLHOUSE_ATTENTE_MACHINE_MS, else ATTENTE_MACHINE_MS); `rappelMs`, the gap between two
183
+ * lines that say the wait goes on (30 s)
184
+ * @returns {T}
185
+ * @throws {AttenteDeLaMachine} at the bound of the wait (code 75), or at once when an ancestor
186
+ * holds the lock (code 1)
187
+ * @template T
188
+ */
189
+ export function avecLaMachine(
190
+ mesure,
191
+ {
192
+ env = process.env,
193
+ dire = l => process.stdout.write(`${l}\n`),
194
+ borneMs = borneDe(env),
195
+ rappelMs = RAPPEL_MS,
196
+ } = {}
197
+ ) {
198
+ if (composantDeLEnveloppe(env)) return mesure(env);
199
+ const verrou = verrouDeMachine(env);
200
+ if (env[VARIABLE_TENUE] && lire(verrou) === env[VARIABLE_TENUE]) return mesure(env);
201
+ mkdirSync(path.dirname(verrou), { recursive: true });
202
+ if (!prendreVerrou(verrou, { attendre: false })) {
203
+ const attendu = attendreLaMachine(verrou, { dire, borneMs, rappelMs });
204
+ dire(`✓ verrou de la machine pris après ${Math.round(attendu / 1000)} s.`);
205
+ }
206
+ try {
207
+ return mesure({ ...env, [VARIABLE_TENUE]: lire(verrou) ?? '' });
208
+ } finally {
209
+ lacherVerrou(verrou);
210
+ }
211
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // The lock of the commands of a role that writes (the testwriter, the developpeur): a PreToolUse hook
3
+ // on Bash, which the settings of its session declare (scripts/droits.mjs). It reads the hook's JSON
4
+ // on stdin and refuses, exit code 2 and its reason on stderr, a command whose shell writes a file
5
+ // out of the session's temporary directory: an output redirection (`>`, `>>`, `>|`, `&>`, `1>`…)
6
+ // or `tee`. A file of the repository is written by the tools of writing (Write, Edit), which the
7
+ // role's write lock (verrou.mjs) judges; a rule of Claude Code does not see a redirection, and a
8
+ // session in its envelope runs without the check of writes.
9
+ //
10
+ // - Allowed targets: the null and standard devices (`/dev/null`, `/dev/stdout`, `/dev/stderr`,
11
+ // `/dev/fd/…`), a duplication of a descriptor (`2>&1`, `>&2`), and a path under the temporary
12
+ // directory (`os.tmpdir()`, the envelope's TMPDIR), written plainly or by `$TMPDIR`/`${TMPDIR}`.
13
+ // A relative path is judged from the command's working directory (the input's `cwd`). A target
14
+ // that names any other variable or a substitution cannot be judged, and is refused.
15
+ // - What quotes hold, and the body of a here-document, is text: a `>` there redirects nothing.
16
+ // - An input it cannot read, or another tool, passes: a hook never stops the session on its own
17
+ // failure.
18
+ import { readFileSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import path from 'node:path';
21
+ import { pathToFileURL } from 'node:url';
22
+
23
+ const PERIPHERIQUES = /^\/dev\/(null|stdout|stderr|tty|fd\/\d+)$/;
24
+
25
+ // The targets of the files <commande> writes by the shell, `$TMPDIR` read as <tmp>: each
26
+ // `{ cible, brute }`, `cible` the word once its quotes are read, null when a variable or a
27
+ // substitution keeps it unknown.
28
+ function ecrituresDuShell(commande, tmp = tmpdir()) {
29
+ const out = [];
30
+ const n = commande.length;
31
+ let i = 0;
32
+ // The here-documents whose bodies start at the next line.
33
+ let documents = [];
34
+ // A word from <j>: its text with quotes read, whether it holds an expansion, and where it ends.
35
+ const mot = j => {
36
+ let texte = '';
37
+ let inconnu = false;
38
+ while (j < n && /\s/.test(commande[j]) && commande[j] !== '\n') j++;
39
+ while (j < n && !/[\s;&|<>()]/.test(commande[j])) {
40
+ const c = commande[j];
41
+ if (c === "'") {
42
+ const f = commande.indexOf("'", j + 1);
43
+ texte += commande.slice(j + 1, f < 0 ? n : f);
44
+ j = f < 0 ? n : f + 1;
45
+ } else if (c === '"') {
46
+ let k = j + 1;
47
+ while (k < n && commande[k] !== '"') {
48
+ if (commande[k] === '\\') k++;
49
+ else if (commande[k] === '$' || commande[k] === '`') inconnu = true;
50
+ texte += commande[k] ?? '';
51
+ k++;
52
+ }
53
+ j = k + 1;
54
+ } else if (c === '\\') {
55
+ texte += commande[j + 1] ?? '';
56
+ j += 2;
57
+ } else {
58
+ if (c === '$' || c === '`') inconnu = true;
59
+ texte += c;
60
+ j++;
61
+ }
62
+ }
63
+ return { texte, inconnu, fin: j };
64
+ };
65
+ const cible = w => {
66
+ const variable = /^\$(\{TMPDIR\}|TMPDIR)(\/.*)?$/.exec(w.texte);
67
+ if (variable) return { cible: path.join(tmp, variable[2] ?? ''), brute: w.texte };
68
+ return { cible: w.inconnu ? null : w.texte, brute: w.texte };
69
+ };
70
+ while (i < n) {
71
+ const c = commande[i];
72
+ if (c === '\n' && documents.length) {
73
+ // The bodies of the here-documents opened on the line just ended.
74
+ let j = i + 1;
75
+ for (const fin of documents) {
76
+ while (j < n) {
77
+ const e = commande.indexOf('\n', j);
78
+ const ligne = commande.slice(j, e < 0 ? n : e);
79
+ j = e < 0 ? n : e + 1;
80
+ if (ligne.replace(/^\t+/, '') === fin) break;
81
+ }
82
+ }
83
+ documents = [];
84
+ i = j;
85
+ continue;
86
+ }
87
+ if (c === "'") {
88
+ const f = commande.indexOf("'", i + 1);
89
+ i = f < 0 ? n : f + 1;
90
+ continue;
91
+ }
92
+ if (c === '"') {
93
+ let k = i + 1;
94
+ while (k < n && commande[k] !== '"') k += commande[k] === '\\' ? 2 : 1;
95
+ i = k + 1;
96
+ continue;
97
+ }
98
+ if (c === '\\') {
99
+ i += 2;
100
+ continue;
101
+ }
102
+ if (c === '<' && commande[i + 1] === '<' && commande[i + 2] !== '<') {
103
+ const w = mot(i + (commande[i + 2] === '-' ? 3 : 2));
104
+ if (w.texte) documents.push(w.texte);
105
+ i = w.fin;
106
+ continue;
107
+ }
108
+ if (c === '>') {
109
+ let j = i + 1;
110
+ if (commande[j] === '>' || commande[j] === '|') j++;
111
+ // A duplication of a descriptor, `>&2`, writes no file.
112
+ if (commande[j] === '&') {
113
+ i = j + 1;
114
+ continue;
115
+ }
116
+ const w = mot(j);
117
+ if (w.texte) out.push(cible(w));
118
+ i = w.fin;
119
+ continue;
120
+ }
121
+ if (c === '&' && commande[i + 1] === '>') {
122
+ const w = mot(i + (commande[i + 2] === '>' ? 3 : 2));
123
+ if (w.texte) out.push(cible(w));
124
+ i = w.fin;
125
+ continue;
126
+ }
127
+ // `tee` as a command word: each of its operands is a file it writes.
128
+ if (/[a-z]/.test(c) && (i === 0 || /[\s;&|(]/.test(commande[i - 1]))) {
129
+ const w = mot(i);
130
+ if (w.texte === 'tee') {
131
+ let j = w.fin;
132
+ for (;;) {
133
+ const a = mot(j);
134
+ if (!a.texte) break;
135
+ if (!a.texte.startsWith('-')) out.push(cible(a));
136
+ j = a.fin;
137
+ }
138
+ i = j;
139
+ } else {
140
+ i = Math.max(w.fin, i + 1);
141
+ }
142
+ continue;
143
+ }
144
+ i++;
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // The reason the command of this hook input is refused, or null when it passes.
150
+ export function refusDeCommande(input, { tmp = tmpdir() } = {}) {
151
+ if (input?.tool_name !== 'Bash') return null;
152
+ const commande = String(input.tool_input?.command ?? '');
153
+ const cwd = input.cwd ?? process.cwd();
154
+ const interieur = f => {
155
+ const rel = path.relative(path.resolve(tmp), f);
156
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
157
+ };
158
+ const enTrop = ecrituresDuShell(commande, tmp).filter(({ cible }) => {
159
+ if (cible === null) return true;
160
+ if (PERIPHERIQUES.test(cible)) return false;
161
+ const absolu = path.resolve(cwd, cible);
162
+ return !interieur(absolu);
163
+ });
164
+ if (!enTrop.length) return null;
165
+ return `Le shell n'écrit pas les fichiers du dépôt (${enTrop.map(e => e.brute).join(', ')}) : un fichier s'écrit par l'outil Write ou Edit, que le verrou de ton rôle juge ; un fichier temporaire va dans ${tmp}.`;
166
+ }
167
+
168
+ const direct = import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
169
+ if (direct) {
170
+ let raison = null;
171
+ try {
172
+ raison = refusDeCommande(JSON.parse(readFileSync(0, 'utf8')));
173
+ } catch {
174
+ // An input it cannot read passes.
175
+ }
176
+ if (raison) {
177
+ process.stderr.write(`${raison}\n`);
178
+ process.exitCode = 2;
179
+ }
180
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // The ticket of a role agent: a PreToolUse hook on the Agent tool, which the frame's plugin declares
3
+ // (`grillhouse ticket-du-sous-agent`). It refuses to launch one of the frame's role agents (the
4
+ // plugin's `agents/<role>.md`, `$CLAUDE_PLUGIN_ROOT`; the agent named bare or as
5
+ // `grillhouse:<role>`) whose prompt does not name its ticket by a « TON TICKET : <id> » line, with
6
+ // exit code 2 and its reason on stderr. The board ties each agent, its time and its tokens to that
7
+ // ticket.
8
+ //
9
+ // - Other agents (exploration, documentation) launch freely.
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ const TICKET = /TON TICKET\s*:\s*[A-Za-z0-9_.-]+/;
15
+
16
+ // The refusal for this launch, or null. `isRole(name)` says whether the frame defines that agent.
17
+ export function refusal(input, isRole) {
18
+ if (input.tool_name !== 'Agent' && input.tool_name !== 'Task') return null;
19
+ const role = input.tool_input?.subagent_type;
20
+ if (!role || !isRole(role)) return null;
21
+ if (TICKET.test(input.tool_input?.prompt ?? '')) return null;
22
+ return `L'agent ${role} part sans son ticket : sa consigne porte la ligne « TON TICKET : <id> ». Ainsi le tableau rattache l'agent, son temps et ses jetons à son ticket.`;
23
+ }
24
+
25
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
26
+ const input = JSON.parse(readFileSync(0, 'utf8'));
27
+ const greffon = process.env.CLAUDE_PLUGIN_ROOT;
28
+ const r = refusal(
29
+ input,
30
+ role =>
31
+ Boolean(greffon) &&
32
+ /^(?:grillhouse:)?[A-Za-z0-9_-]+$/.test(role) &&
33
+ existsSync(path.join(greffon, 'agents', `${role.replace(/^grillhouse:/, '')}.md`))
34
+ );
35
+ if (r) {
36
+ process.stderr.write(`⛔ ${r}\n`);
37
+ process.exitCode = 2;
38
+ }
39
+ }