@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.
- package/.claude-plugin/plugin.json +9 -0
- package/METACADRE.md +41 -0
- package/agents/developpeur.md +10 -0
- package/agents/explorateur.md +9 -0
- package/agents/questionneur.md +11 -0
- package/agents/referent-technique.md +9 -0
- package/agents/regulateur.md +11 -0
- package/agents/relecteur.md +9 -0
- package/agents/testwriter.md +10 -0
- package/bin/grillhouse.mjs +125 -0
- package/cadre/templates/projet/.claude/settings.json +30 -0
- package/cadre/templates/projet/.editorconfig +47 -0
- package/cadre/templates/projet/.env.example +57 -0
- package/cadre/templates/projet/.eslintrc.cjs +56 -0
- package/cadre/templates/projet/.github/workflows/ci.yml +66 -0
- package/cadre/templates/projet/.mcp.json +13 -0
- package/cadre/templates/projet/.prettierrc +11 -0
- package/cadre/templates/projet/CHANGELOG.md +61 -0
- package/cadre/templates/projet/CLAUDE.md +46 -0
- package/cadre/templates/projet/CONTRIBUTING.md +243 -0
- package/cadre/templates/projet/CREDITS.md +41 -0
- package/cadre/templates/projet/README.md +152 -0
- package/cadre/templates/projet/_gitignore +91 -0
- package/cadre/templates/projet/_npmignore +46 -0
- package/cadre/templates/projet/deployment/README.md +98 -0
- package/cadre/templates/projet/deployment/docs/01-INSTALL.md +145 -0
- package/cadre/templates/projet/deployment/docs/02-CONFIGURATION.md +117 -0
- package/cadre/templates/projet/deployment/docs/03-API.md +264 -0
- package/cadre/templates/projet/deployment/docs/04-TROUBLESHOOTING.md +262 -0
- package/cadre/templates/projet/deployment/scripts/install.ps1 +100 -0
- package/cadre/templates/projet/deployment/scripts/start.ps1 +61 -0
- package/cadre/templates/projet/deployment/scripts/stop.ps1 +37 -0
- package/cadre/templates/projet/deployment/scripts/test.ps1 +118 -0
- package/cadre/templates/projet/docs/agents/issue-tracker.md +44 -0
- package/cadre/templates/projet/ecosystem.config.cjs +73 -0
- package/cadre/templates/projet/package.json +90 -0
- package/cadre/templates/projet/pitmaster/SUIVI.md +22 -0
- package/cadre/templates/projet/src/config.ts +68 -0
- package/cadre/templates/projet/src/errors.ts +102 -0
- package/cadre/templates/projet/src/index.ts +46 -0
- package/cadre/templates/projet/src/services/.gitkeep +8 -0
- package/cadre/templates/projet/src/types/index.ts +24 -0
- package/cadre/templates/projet/src/utils/logger.ts +106 -0
- package/cadre/templates/projet/tests/integration/.gitkeep +7 -0
- package/cadre/templates/projet/tests/unit/config.spec.ts +68 -0
- package/cadre/templates/projet/tests/unit/errors.spec.ts +132 -0
- package/cadre/templates/projet/tests/unit/types.spec.ts +73 -0
- package/cadre/templates/projet/tsconfig.eslint.json +9 -0
- package/cadre/templates/projet/tsconfig.json +41 -0
- package/cadre/templates/projet/tsdoc.json +6 -0
- package/cadre/templates/projet/vitest.config.ts +44 -0
- package/cadre/templates/turbo/paquets.json +29 -0
- package/cadre/templates/turbo/sans-paquets.json +28 -0
- package/charte.md +107 -0
- package/docs/ARCHITECTURE.md +90 -0
- package/hooks/crochet.mjs +33 -0
- package/hooks/hooks.json +58 -0
- package/hooks/register.tsx +558 -0
- package/package.json +75 -4
- package/scripts/avancement.mjs +244 -0
- package/scripts/bilan.mjs +523 -0
- package/scripts/branche-de-nuit.mjs +59 -0
- package/scripts/cadre.mjs +109 -0
- package/scripts/circuit.mjs +4375 -0
- package/scripts/code-mort.mjs +371 -0
- package/scripts/composants-affectes.mjs +598 -0
- package/scripts/consommateurs-arbitres.mjs +192 -0
- package/scripts/consommateurs.mjs +310 -0
- package/scripts/crochet.mjs +24 -0
- package/scripts/crochets.mjs +135 -0
- package/scripts/dernier-vert.mjs +208 -0
- package/scripts/dette.mjs +181 -0
- package/scripts/droits.mjs +354 -0
- package/scripts/enveloppe/codes.mjs +17 -0
- package/scripts/enveloppe/composant.mjs +11 -0
- package/scripts/enveloppe/enveloppe.mjs +628 -0
- package/scripts/enveloppe/lancer.sh +311 -0
- package/scripts/etapes.mjs +323 -0
- package/scripts/etat-du-circuit.mjs +104 -0
- package/scripts/frontieres.mjs +456 -0
- package/scripts/gardes-du-cadre.mjs +72 -0
- package/scripts/greffons.mjs +338 -0
- package/scripts/grillhouse-maj.mjs +514 -0
- package/scripts/index-interfaces.mjs +293 -0
- package/scripts/init.mjs +117 -0
- package/scripts/integration/file-attente.mjs +61 -0
- package/scripts/integration/file.mjs +494 -0
- package/scripts/integration/integrer.mjs +381 -0
- package/scripts/integration/rejeu.mjs +139 -0
- package/scripts/integration/verdict.mjs +13 -0
- package/scripts/interfaces-contre-code.mjs +547 -0
- package/scripts/ligne-etat/defaut.sh +110 -0
- package/scripts/ligne-etat/ligne-etat.mjs +93 -0
- package/scripts/migrations/0.23.0-charte-importee.mjs +47 -0
- package/scripts/migrations/0.23.0-config-vitest.mjs +105 -0
- package/scripts/migrations/0.23.0-crochets-de-seance.mjs +55 -0
- package/scripts/migrations/0.23.0-marche-du-projet.mjs +82 -0
- package/scripts/migrations.mjs +43 -0
- package/scripts/nuit.sh +269 -0
- package/scripts/paquets.mjs +79 -0
- package/scripts/poussee-cles.mjs +46 -0
- package/scripts/preparation.mjs +84 -0
- package/scripts/references-par-les-types.mjs +420 -0
- package/scripts/registre.mjs +336 -0
- package/scripts/resultats.mjs +580 -0
- package/scripts/resumes.mjs +57 -0
- package/scripts/retirer-copie.sh +127 -0
- package/scripts/session-start.sh +203 -0
- package/scripts/sortie.mjs +155 -0
- package/scripts/sous-modules.sh +316 -0
- package/scripts/structure-guard.sh +27 -0
- package/scripts/superviseur.mjs +102 -0
- package/scripts/tableau.mjs +1886 -0
- package/scripts/tests.mjs +236 -0
- package/scripts/ticket.sh +23 -0
- package/scripts/turbo-des-paquets.mjs +899 -0
- package/scripts/un-commit-par-ticket.mjs +212 -0
- package/scripts/verifier.mjs +469 -0
- package/scripts/verrou-de-fichier.mjs +130 -0
- package/scripts/verrou-de-machine.mjs +211 -0
- package/scripts/verrous/commande.mjs +180 -0
- package/scripts/verrous/ticket-du-sous-agent.mjs +39 -0
- package/scripts/verrous/verrou.mjs +165 -0
- package/scripts/vitest-config/avancement.mjs +39 -0
- package/scripts/vitest-config/avancement.reporter.mjs +56 -0
- package/scripts/vitest-config/base-de-passe.mjs +128 -0
- package/scripts/vitest-config/base.d.ts +6 -0
- package/scripts/vitest-config/base.mjs +87 -0
- package/scripts/vitest-config/changements.mjs +173 -0
- package/scripts/vitest-config/cibles.mjs +207 -0
- package/scripts/vitest-config/duree.reporter.mjs +98 -0
- package/scripts/vitest-config/machine.setup.mjs +14 -0
- package/scripts/vitest-config/mesure.d.ts +6 -0
- package/scripts/vitest-config/mesure.mjs +13 -0
- package/scripts/vitest-config/mesure.setup.mjs +12 -0
- package/scripts/vitest-config/pression.mjs +90 -0
- package/scripts/vitest-config/resultats.mjs +23 -0
- package/scripts/vitest-config/resultats.setup.mjs +19 -0
- package/scripts/vitest.nuit.config.mjs +26 -0
- package/skills/developpeur/SKILL.md +175 -0
- package/skills/explorateur/SKILL.md +84 -0
- package/skills/grill/SKILL.md +238 -0
- package/skills/grill/references/releve-structurel.md +38 -0
- package/skills/mesure/SKILL.md +168 -0
- package/skills/pitmaster/SKILL.md +382 -0
- package/skills/pitmaster/references/cadre.md +119 -0
- package/skills/pitmaster/references/consigne-agent.md +308 -0
- package/skills/pitmaster/references/erreurs.md +25 -0
- package/skills/pitmaster/references/tour.md +124 -0
- package/skills/questionneur/SKILL.md +64 -0
- package/skills/redacteur/SKILL.md +84 -0
- package/skills/redacteur/references/documents-d-un-composant.md +105 -0
- package/skills/referent-technique/SKILL.md +145 -0
- package/skills/regulateur/SKILL.md +220 -0
- package/skills/release/SKILL.md +134 -0
- package/skills/release/scripts/update-version.cjs +92 -0
- package/skills/relecteur/SKILL.md +152 -0
- package/skills/relecteur/references/LICENSE-MIT +30 -0
- package/skills/relecteur/references/SOURCE.md +8 -0
- package/skills/relecteur/references/claims-check.md +14 -0
- package/skills/relecteur/references/deletion-check.md +14 -0
- package/skills/relecteur/references/edge-case-hunter.md +80 -0
- package/skills/relecteur/references/verification-gap.md +113 -0
- package/skills/testwriter/SKILL.md +187 -0
- package/types/index.d.ts +56 -0
- 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
|
+
}
|