@sylad/cadence 1.33.0 → 1.35.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/NOTICE +28 -0
  4. package/README.md +2 -2
  5. package/agents/qa-reviewer.md +5 -4
  6. package/agents/spec-writer.md +2 -0
  7. package/agents/ux-reviewer.md +28 -0
  8. package/dist/cli.js +13 -29
  9. package/dist/config.js +16 -30
  10. package/dist/corrections-sans-code.js +12 -0
  11. package/dist/corrections.js +4 -5
  12. package/dist/deliver.js +4 -4
  13. package/dist/gate-config.js +47 -0
  14. package/dist/orchestrate/accept.js +5 -2
  15. package/dist/orchestrate/baseline.js +57 -0
  16. package/dist/orchestrate/briefs.js +5 -1
  17. package/dist/orchestrate/command.js +6 -6
  18. package/dist/orchestrate/cycle.js +119 -140
  19. package/dist/orchestrate/gate-notes.js +27 -0
  20. package/dist/orchestrate/gate-parts.js +135 -0
  21. package/dist/orchestrate/gate-place.js +66 -0
  22. package/dist/orchestrate/precheck.js +79 -0
  23. package/dist/orchestrate/probe.js +124 -0
  24. package/dist/orchestrate/schemas.js +10 -1
  25. package/dist/orchestrate/state.js +1 -1
  26. package/dist/orchestrate/workers.js +31 -4
  27. package/dist/perf.js +1 -1
  28. package/dist/plan-commit.js +4 -2
  29. package/dist/plan-uncommitted.js +26 -0
  30. package/dist/release.js +9 -7
  31. package/dist/remote-gate.js +278 -0
  32. package/dist/verify-gate-remote.js +55 -0
  33. package/dist/verify-gate.js +37 -28
  34. package/dist/yaml-keys.js +6 -0
  35. package/docs/corrections.md +4 -0
  36. package/docs/deliver.md +4 -2
  37. package/docs/news.md +2 -4
  38. package/docs/orchestrate.md +40 -2
  39. package/docs/overview.md +1 -0
  40. package/docs/raf.md +3 -3
  41. package/docs/releasing.md +16 -7
  42. package/docs/remote-gate.md +89 -0
  43. package/docs/skills.md +5 -2
  44. package/docs/whats-new.md +9 -0
  45. package/package.json +3 -1
  46. package/skills/lead/SKILL.md +1 -1
  47. package/templates/orchestrate/probe.md +20 -0
  48. package/templates/orchestrate/spec.md +3 -1
@@ -0,0 +1,135 @@
1
+ // Porte de tests déclarée en parties (L512) : `orchestrate.test: [{ dir, run, replay? }]`. Le 31-10, la porte composée de warhammer (`backend && frontend`) refusait `testReplay`,
2
+ // donc ni le rejeu seul (L354) ni le délai trop juste et le rouge au départ (L510) : quatre corrections et une escalade pour des faux rouges (L61). Décision de Sylvain du 01-11 :
3
+ // cadence lance chaque partie séparément, dans son dossier, et attribue chaque rouge à la partie qui l'a imprimé ; une partie sans `replay` garde ses rouges comptés ; la forme chaîne reste telle quelle.
4
+ // Ce module ne change que le lieu du lancement et l'attribution des noms : les règles de verdict (deux rejeux, fichier vu dans la sortie, délai trop juste) restent celles de workers.ts.
5
+ import { isAbsolute, posix } from 'node:path';
6
+ import { failedTests, hasUnnamedFailure } from './baseline.js';
7
+ import { isComposedGate, redAlone, replayRed, shellArg } from './workers.js';
8
+ export const isParts = (t) => Array.isArray(t);
9
+ const PART_KEYS = ['dir', 'run', 'replay'];
10
+ /** Le dossier d'une partie, normalisé (`./front/` → `front`) ; null s'il est vide, absolu ou contient « .. ». */
11
+ function partDir(v) {
12
+ if (typeof v !== 'string' || !v.trim() || isAbsolute(v.trim()) || v.split(/[\\/]/).includes('..'))
13
+ return null;
14
+ const n = posix.normalize(v.trim().replaceAll('\\', '/')).replace(/\/+$/, '');
15
+ return n || null;
16
+ }
17
+ /** Lecture de `orchestrate.test` (contrat A1) : la chaîne telle quelle, ou la liste des parties validées ; `bad` fabrique l'erreur de cadence.yaml. */
18
+ export function readGateTest(raw, bad) {
19
+ if (typeof raw === 'string') {
20
+ if (!raw.trim())
21
+ throw bad('test : commande non vide attendue');
22
+ return raw.trim();
23
+ }
24
+ if (!Array.isArray(raw))
25
+ throw bad('test : commande non vide attendue (chaîne ou liste de parties { dir, run, replay? })');
26
+ if (!raw.length)
27
+ throw bad('test : au moins une partie attendue (dir, run, replay facultatif)');
28
+ const seen = new Set();
29
+ return raw.map((item) => {
30
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
31
+ throw bad('test : chaque partie doit être un objet { dir, run, replay? }');
32
+ const o = item;
33
+ for (const k of Object.keys(o))
34
+ if (!PART_KEYS.includes(k))
35
+ throw bad(`test : clé « ${k} » inconnue dans une partie (attendu : dir, run, replay)`);
36
+ const dir = partDir(o.dir);
37
+ if (dir === null || seen.has(dir))
38
+ throw bad(`test : dir invalide « ${typeof o.dir === 'string' ? o.dir : JSON.stringify(o.dir)} » (chemin relatif, sans « .. », unique)`);
39
+ seen.add(dir);
40
+ if (typeof o.run !== 'string' || !o.run.trim())
41
+ throw bad(`test : la commande run de la partie « ${dir} » ne peut pas être vide (chaîne non vide attendue)`);
42
+ const part = { dir, run: o.run.trim() };
43
+ if (o.replay != null) {
44
+ if (typeof o.replay !== 'string' || !o.replay.trim() || !o.replay.includes('{files}'))
45
+ throw bad(`test : le replay de la partie « ${dir} » doit être une commande non vide contenant {files} (remplacé par les fichiers des tests rouges)`);
46
+ part.replay = o.replay.trim();
47
+ }
48
+ return part;
49
+ });
50
+ }
51
+ /** `orchestrate.testReplay` face à `orchestrate.test` (L354, L512) : `{files}` exigé ; refusé avec une liste de parties (le rejeu se déclare par partie) et sur une porte composée en chaîne. */
52
+ export function checkTestReplay(test, replay, bad) {
53
+ if (!replay)
54
+ return;
55
+ if (!replay.includes('{files}'))
56
+ throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
57
+ if (isParts(test))
58
+ throw bad('testReplay : refusé quand orchestrate.test est une liste de parties : déclarer replay dans chaque partie');
59
+ if (test && isComposedGate(test))
60
+ throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
61
+ }
62
+ /** Le libellé d'une porte (R9 b) : la chaîne telle quelle ; les parties « dir : run » jointes par « ; ». Sert aux messages, aux briefs, au CheckRun et à la clé de cache de la ligne de base. */
63
+ export const gateLabel = (t) => (isParts(t) ? t.map((p) => `${p.dir} : ${p.run}`).join(' ; ') : t);
64
+ /** La porte a de quoi rejouer un rouge seul : `testReplay` pour une chaîne, au moins une partie avec `replay` pour une liste. */
65
+ export const gateReplayable = (t, replay) => (isParts(t) ? t.some((p) => !!p.replay) : !!replay);
66
+ /** La commande d'une partie jouée depuis la racine du lieu (worktree local ou de l'hôte) : un dossier introuvable fait sortir le shell en échec en le nommant (« can't cd to dir »), sans test nommé. */
67
+ export const partCommand = (p, command = p.run) => `cd ${shellArg(p.dir)} && ${command}`;
68
+ /** Joue la porte : la chaîne en un bloc ; les parties toutes, chacune dans son dossier (R8 : une partie rouge ne coupe pas les suivantes), code de la première non nulle, sorties fusionnées sous un en-tête « [dir] ». */
69
+ export async function runGate(run, test) {
70
+ if (!isParts(test))
71
+ return run(test);
72
+ const parts = [];
73
+ for (const part of test) {
74
+ const r = await run(partCommand(part));
75
+ parts.push({ part, code: r.code, output: r.output });
76
+ }
77
+ return { code: parts.find((p) => p.code !== 0)?.code ?? 0, output: parts.map((p) => `[${p.part.dir}]\n${p.output}`).join('\n'), parts };
78
+ }
79
+ /**
80
+ * La porte a échoué pour autre chose qu'un test nommé : forme chaîne, `hasUnnamedFailure` (L354) ; parties, une partie rouge qui ne nomme aucun test (erreur de compilation, « can't cd to », lanceur
81
+ * planté) ou dont la sortie porte un échec sans nom (seuil de couverture, fichier qui ne se charge pas). Rien n'est alors rejoué ni excusé : la porte reste rouge, quoi que le rejeu des autres parties dise (revue du 01-11, bloquant : jamais de faux vert).
82
+ */
83
+ export function gateUnnamed(ran) {
84
+ if (!ran.parts)
85
+ return hasUnnamedFailure(ran.output);
86
+ return ran.parts.some((p) => p.code !== 0 && (hasUnnamedFailure(p.output) || failedTests(p.output).length === 0));
87
+ }
88
+ /** Les rouges nommés de la porte du LOT, candidats au rejeu seul : vide dès que la porte a un échec sans test nommé (`gateUnnamed`), en chaîne comme en parties — rien n'excuse alors le lot. */
89
+ export function redCandidates(ran) {
90
+ return gateUnnamed(ran) ? [] : failedTests(ran.output);
91
+ }
92
+ /**
93
+ * Les rouges nommés de la LIGNE DE BASE, candidats au rejeu seul : là, rejouer retire des excuses (un rouge de charge du départ ne doit pas excuser une vraie régression du lot), donc chaque partie est jugée pour elle-même :
94
+ * une partie rouge sans test nommé au départ ne retient que ses propres rouges (aucun), les noms des autres parties sont rejoués par leur partie (`perPart`). Forme chaîne : comme avant (tout ou rien).
95
+ */
96
+ export function baselineCandidates(ran) {
97
+ return ran.parts ? failedTests(ran.output) : redCandidates(ran);
98
+ }
99
+ /** Par partie rouge, les noms de `names` qu'elle a imprimés et le verdict de `judge` sur eux seuls ; null (rien de prouvable) pour une partie sans `replay` ou à échec sans nom. */
100
+ async function perPart(parts, names, judge) {
101
+ const out = [];
102
+ for (const p of parts) {
103
+ if (p.code === 0)
104
+ continue;
105
+ const printed = new Set(failedTests(p.output));
106
+ const own = names.filter((n) => printed.has(n));
107
+ if (own.length)
108
+ out.push({ own, v: p.part.replay && !hasUnnamedFailure(p.output) ? await judge(p, own) : null });
109
+ }
110
+ return out;
111
+ }
112
+ /** Un nom est excusé seulement si CHAQUE partie qui l'a imprimé rouge l'excuse (règle du lead du 01-11 : jamais de faux vert) ; un nom qu'aucune partie rouge n'a imprimé ne l'est pas. */
113
+ const everyPart = (judged, names, ok) => [...new Set(names)].filter((n) => {
114
+ const mine = judged.filter((j) => j.own.includes(n));
115
+ return mine.length > 0 && mine.every((j) => j.v !== null && ok(j.v, n));
116
+ });
117
+ /**
118
+ * Rejeu seul des rouges `names` (L354, L510) : forme chaîne, `replayRed` tel quel ; parties, chaque partie rouge rejoue dans son dossier, par son `replay`, les seuls noms qu'elle a imprimés (R4),
119
+ * et un nom imprimé par plusieurs parties n'est instable que si toutes le voient vert (R5) ; le délai trop juste d'un instable est celui lu dans l'une d'elles.
120
+ */
121
+ export async function replayGate(run, replay, names, ran) {
122
+ if (!ran.parts)
123
+ return replayRed(run, replay, names, ran.output);
124
+ const judged = await perPart(ran.parts, names, (p, own) => replayRed((c) => run(partCommand(p.part, c)), p.part.replay, own, p.output));
125
+ const unstable = everyPart(judged, names, (v, n) => v.unstable.includes(n));
126
+ const tight = unstable.flatMap((n) => judged.map((j) => j.v?.tight.find((k) => k.test === n)).filter((k) => !!k).slice(0, 1));
127
+ return { red: [...new Set(names)].filter((n) => !unstable.includes(n)), unstable, tight };
128
+ }
129
+ /** Rouges seuls au sha de départ (L510, R6) : forme chaîne, `redAlone` tel quel ; parties, un nom n'est « déjà rouge » que si chaque partie qui l'a imprimé rouge dans le lot le nomme encore en échec à ses rejeux au départ. */
130
+ export async function redAloneGate(run, replay, names, ran) {
131
+ if (!ran.parts)
132
+ return redAlone(run, replay, names, ran.output);
133
+ const judged = await perPart(ran.parts, names, (p, own) => redAlone((c) => run(partCommand(p.part, c)), p.part.replay, own, p.output));
134
+ return everyPart(judged, names, (v, n) => v.includes(n));
135
+ }
@@ -0,0 +1,66 @@
1
+ // Où une commande de la porte d'un lot tourne (L403) : sur l'hôte `orchestrate.gate.host` quand il répond et que l'hôte verrait ce qui est testé, sinon en local.
2
+ // Les décisions de verdict (rouge, ligne de base, rejeu seul) restent celles du cycle : ce module ne change que le lieu et ne rend jamais un 255 comme un résultat.
3
+ import { GATE_TIMEOUT_MS, isSha, openRemoteGate } from '../remote-gate.js';
4
+ import { configuredCap } from './command.js';
5
+ import { testWorkersFrom } from './workers.js';
6
+ import { trackedDirty, untrackedFiles } from './worktree.js';
7
+ const localPlace = (local) => ({ run: local, stamp: () => ({}), close: () => { } });
8
+ /**
9
+ * Ouvre le lieu de la porte. Sans `gate.host`, ou quand l'hôte ne peut pas porter la porte (arbre non validé R7, hôte injoignable, push ou cache impossible R4/R5), la porte est locale et la ligne dit pourquoi.
10
+ * Sur l'hôte, un code 255 de ssh (connexion perdue) n'est jamais un résultat : la commande est rejouée en local, la ligne le dit, et le lieu reste local pour les commandes suivantes (R6).
11
+ */
12
+ async function openPlace(o) {
13
+ const host = o.config.gate?.host;
14
+ // Aucune commande de porte déclarée : rien à jouer, ni sonde ni worktree sur l'hôte.
15
+ if (!host || !o.sha || !(o.config.build || o.config.test))
16
+ return localPlace(o.local);
17
+ if (o.needClean) {
18
+ const dirty = [...trackedDirty(o.dir), ...untrackedFiles(o.dir)];
19
+ if (dirty.length) {
20
+ o.say(`${o.label} : worktree non validé, jouée en local (l'hôte ne verrait pas les modifications)`);
21
+ return localPlace(o.local);
22
+ }
23
+ }
24
+ if (!isSha(o.sha)) {
25
+ o.say(`${o.label} : sha invalide pour la porte déportée (hexadécimal attendu), jouée en local`);
26
+ return localPlace(o.local);
27
+ }
28
+ const env = o.env ?? process.env;
29
+ const opened = await openRemoteGate({ host, install: o.config.gate?.install, repo: o.repo, sha: o.sha, depsFrom: o.repo, worktreeDeps: o.config.worktreeDeps, env, timeoutMs: GATE_TIMEOUT_MS, workers: (cores) => testWorkersFrom(env, cores, configuredCap(env)) });
30
+ if (!opened.ok) {
31
+ o.say(opened.stage === 'host' ? `${o.label} : porte déportée indisponible sur ${host} (${opened.cause}) — jouée en local` : `${o.label} : cache de dépendances impossible sur ${host} (${opened.cause}) — jouée en local`);
32
+ return localPlace(o.local);
33
+ }
34
+ const gate = opened.gate;
35
+ let lost = false;
36
+ const where = `hôte ${host}, sha ${o.sha.slice(0, 7)}, ${gate.cache}`;
37
+ o.say(`${o.label} : porte jouée sur l'hôte ${host} (sha ${o.sha.slice(0, 7)}, ${gate.cache})`);
38
+ return {
39
+ run: async (command) => {
40
+ if (lost)
41
+ return o.local(command);
42
+ const r = await gate.run(command, GATE_TIMEOUT_MS);
43
+ if (!r.lost)
44
+ return r;
45
+ lost = true;
46
+ o.say(`${o.label} : connexion perdue avec ${host} pendant la porte — rejouée en local${o.detail ? ` ${o.detail}` : ''}`);
47
+ return o.local(command);
48
+ },
49
+ stamp: () => ({ lieu: lost ? `local (connexion perdue avec ${host})` : where }),
50
+ close: () => {
51
+ const orphan = gate.close();
52
+ if (orphan)
53
+ o.say(`${o.label} : nettoyage de l'hôte ${host} en échec (${orphan}) — le worktree orphelin y sera retiré au prochain appel`);
54
+ },
55
+ };
56
+ }
57
+ /** Joue `fn` dans le lieu de la porte et retire le worktree de l'hôte quoi qu'il arrive (R10). */
58
+ export async function withPlace(o, fn) {
59
+ const place = await openPlace(o);
60
+ try {
61
+ return await fn(place);
62
+ }
63
+ finally {
64
+ place.close();
65
+ }
66
+ }
@@ -0,0 +1,79 @@
1
+ // Les critères du contrôle préalable (L77, L288, L292, L322) : un lot qui cite un constat ou un autre lot, et un lot créé après le dernier commit de code, qui n'a rien à retrouver.
2
+ import { relative } from 'node:path';
3
+ import { isPlanOnly, lotWork } from '../audit.js';
4
+ import { git } from './worktree.js';
5
+ /**
6
+ * Mots du titre ou du goal qui disent que le lot reprend un constat : le livrable peut déjà exister. Mots entiers (« review » ne
7
+ * se lit pas dans `qa-reviewer` ni `review-small`), au pluriel ou accordés pour « constat ».
8
+ */
9
+ const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-])/i;
10
+ /**
11
+ * Le texte du lot (titre, goal, objectif donné) cite un constat, ou un autre lot dont le travail a pu être fait. Pour un lot
12
+ * `L<n>`, « L12 » compte dans le texte de `L30` (aussi dans « L12-L15 », « L12-bis », « L12.3 ») et non celui de `L12` ni d'un lot postérieur ; tout identifiant du plan qui n'est
13
+ * pas `L<n>` (`Q-CAN5`, `B28.1`, `Q4/qa-expectations`) compte cité en entier (`B28.1` n'est pas `B28.10`), pour un lot `L<n>`
14
+ * comme pour un autre ; un lot d'un autre projet (`cadence:L68`) compte toujours.
15
+ */
16
+ export function citesPriorWork(text, lot, ids) {
17
+ if (CITES_FINDING.test(text))
18
+ return true;
19
+ const { ids: cited, foreign } = citedLots(text, lot, ids);
20
+ return foreign || cited.length > 0;
21
+ }
22
+ /**
23
+ * Les lots que le texte cite (même reconnaissance que `citesPriorWork`, L288/L292) : les identifiants du plan cités, et `foreign` quand il cite un lot d'un autre projet
24
+ * (`cadence:L68`, dont on ne lit pas les commits ici).
25
+ */
26
+ function citedLots(text, lot, ids) {
27
+ const foreign = /(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text);
28
+ const own = /^L(\d+)$/.exec(lot);
29
+ const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
30
+ return {
31
+ foreign,
32
+ ids: ids.filter((id) => {
33
+ if (id === lot)
34
+ return false;
35
+ const n = /^L(\d+)$/.exec(id);
36
+ if (own && n)
37
+ return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
38
+ return cited(id);
39
+ }),
40
+ };
41
+ }
42
+ /**
43
+ * Un lot créé après le dernier commit de code du dépôt n'a rien à retrouver (L322) : tout ce que le dépôt contient date d'avant le lot, donc aucun autre lot ne l'a livré.
44
+ * Rend la raison à écrire au journal, ou null quand le contrôle garde un sens. Le lot est daté par le commit qui a ajouté son id au plan, borné par sa clé `created` (une
45
+ * migration ou un déplacement du plan ne rajeunit pas un vieux lot) ; le dernier commit de code est le dernier qui ne touche pas que le plan (`isPlanOnly`). Prudent : lot
46
+ * qui travaille dans des dépôts voisins, id introuvable dans l'historique du plan, dépôt sans commit de code ou instants égaux → le contrôle reste.
47
+ * Le contrôle reste aussi quand le titre ou le goal (ou le goal donné au lancement) cite un lot qui a déjà ses propres commits, ou un lot d'un autre projet : une « suite de revue L<n> » écrite
48
+ * juste après le dernier correctif de L<n> liste des constats que les passes de correction de L<n> ont pu régler avant la création du lot (rejoué depuis le 24-10 : 5 lots sautés à tort,
49
+ * dont 2 avec un vrai « partiel »).
50
+ */
51
+ export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
52
+ if (repos.length)
53
+ return null;
54
+ try {
55
+ const rel = relative(root, plan.path);
56
+ const idLine = `^\\s*- id: ["']?${lot.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["']?\\s*$`;
57
+ // Pas de `--reverse` : avec `--follow`, git 2.43 ne rend rien sur un fichier renommé (`raf migrate`) ; le plus ancien est la dernière ligne.
58
+ const added = git(root, '-c', 'core.quotepath=off', 'log', '--follow', '--format=%ct', '-G', idLine, '--', rel).split('\n').filter(Boolean).pop();
59
+ if (!added)
60
+ return null;
61
+ const created = plan.lot(lot).created;
62
+ const endOfCreated = created ? Date.parse(`${created}T23:59:59`) / 1000 : Infinity;
63
+ const createdAt = Math.min(Number(added), Number.isNaN(endOfCreated) ? Infinity : endOfCreated);
64
+ const log = git(root, 'log', '--format=%H %ct').split('\n').filter(Boolean);
65
+ const code = log.map((x) => x.split(' ')).find(([sha]) => !isPlanOnly(sha, plan, root));
66
+ if (!code)
67
+ return null;
68
+ if (createdAt <= Number(code[1]))
69
+ return null;
70
+ const l = plan.lot(lot);
71
+ const { ids, foreign } = citedLots([l.title, l.goal, goal].filter(Boolean).join('\n'), lot, plan.lots().map((x) => x.id));
72
+ if (foreign || ids.some((id) => lotWork(plan, root, id).length > 0))
73
+ return null;
74
+ return `lot créé après le dernier commit de code du dépôt (${code[0].slice(0, 7)}) : rien à retrouver`;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
@@ -0,0 +1,124 @@
1
+ import { docSyncSet, filesOf } from '../docsync.js';
2
+ import { readCommits } from '../git.js';
3
+ import { checkShape, PROBE_SCHEMA } from './schemas.js';
4
+ /** Rendu par la session de la sonde quand elle n'a rien donné (échec, coupure, délai) : la sonde ne bloque jamais le lot, un warning et la revue part. */
5
+ export const PROBE_FAILED = Symbol('probe-failed');
6
+ /**
7
+ * Coût réservé à la sonde en plus de la correction et de la revue qui la suit : une session Sonnet d'écriture. ESTIMATION, aucune mesure (la sonde n'a pas encore joué) :
8
+ * le p90 d'une passe fix Sonnet, 65 k tokens comptés (mesure du 2026-10-15, voir REVIEW_RESERVE). À remesurer sur les premières sondes (`cadence perf`, étape probe).
9
+ */
10
+ export const PROBE_RESERVE = 65_000;
11
+ /** Préfixe du texte d'un constat de la sonde : il distingue, dans le journal, la passe de correction qu'elle déclenche. */
12
+ const PROBE_FINDING = 'sonde de cas limites : ';
13
+ /**
14
+ * Décide la sonde d'un lot (R3, R6) : vrai quand elle est la prochaine étape. Décidée une seule fois par lot (`l.probe`), jouée ou écartée avec un warning qui dit pourquoi.
15
+ * Sans la clé `orchestrate.probe`, rien : ni champ ni warning.
16
+ */
17
+ export function scheduleProbe(config, l, o) {
18
+ if (!config.probe || l.probe || o.skip)
19
+ return false;
20
+ l.probe = true;
21
+ if (!o.late && o.pays) {
22
+ l.next = 'probe';
23
+ return true;
24
+ }
25
+ l.warnings.push(`sonde de cas limites non jouée : ${l.pass} passe(s) de correction déjà faite(s) ou budget du lot (${o.count}) insuffisant`);
26
+ return false;
27
+ }
28
+ /** Les variables du brief de la sonde : la fiche du lot quand il en a une, ses commits, et la commande du diff (jamais le diff collé). */
29
+ export function probeVars(fiche, commits, from, lot) {
30
+ return {
31
+ fiche: fiche ? `The lot has a spec sheet: \`${fiche}\`. Read it first: each of its rules (Rn) states a behaviour to attack, and its contracts give the exact errors the code must raise.` : '',
32
+ commits,
33
+ diff: from ? `git diff ${from}..HEAD` : `git log -p --reverse --grep=${lot}`,
34
+ };
35
+ }
36
+ const unplayed = (l, why) => l.warnings.push(`sonde de cas limites non jouée : ${why}`);
37
+ /** Le rapport de la session, ou un warning « non jouée » quand il est illisible (la revue part quand même). */
38
+ function readReport(l, report) {
39
+ try {
40
+ return checkShape(report, PROBE_SCHEMA);
41
+ }
42
+ catch (e) {
43
+ unplayed(l, e.message);
44
+ return undefined;
45
+ }
46
+ }
47
+ /** Les fichiers commités par la sonde qui ne sont pas des tests (R8) : un warning les nomme, rien n'est retiré. Sans `testGate.tests` déclaré, rien ne distingue un test d'un autre. */
48
+ function warnOutsideTests(io, headBefore) {
49
+ const tests = io.config.testGate?.tests;
50
+ if (!tests)
51
+ return;
52
+ const isTest = docSyncSet(tests);
53
+ const outside = [...filesOf(io.cwd, readCommits(io.cwd, { range: headBefore ? `${headBefore}..HEAD` : undefined }))].filter((f) => !isTest(f)).sort();
54
+ if (outside.length)
55
+ io.lot.warnings.push(`sonde de cas limites : la session a modifié du code hors tests (${outside.join(', ')})`);
56
+ }
57
+ /** Les contrôles faits après une session qui a commité, avec ou sans rapport : verrou, dépôt propre (`dirt`), fichiers hors tests. Vrai quand le lot est rendu. */
58
+ function handedBack(io, dirt, headBefore) {
59
+ const locked = io.sealed(headBefore);
60
+ const why = locked.length ? `verrou des tests d'acceptation : ${locked.join(' ; ')}` : dirt.length ? `dépôt sale après probe : ${dirt.join(', ')}` : '';
61
+ if (why)
62
+ io.handBack(why);
63
+ else
64
+ warnOutsideTests(io, headBefore);
65
+ return !!why;
66
+ }
67
+ /**
68
+ * Les constats de la sonde (R4, R5) : les rouges NOUVEAUX de la porte du programme (jamais un échec déjà rouge au départ), en gravité majeure, avec ce que la session annonce
69
+ * (« test — borne — attendu ») pour que la correction voie la borne. Sans `orchestrate.test`, les échecs annoncés tels quels, dits non vérifiés par le programme.
70
+ */
71
+ function findings(l, red, claimed, gated) {
72
+ const said = claimed.length ? `\nLa sonde annonce : ${claimed.join(' ; ')}` : '';
73
+ const out = red.map((k) => ({ ...k, gravite: 'majeur', texte: `${PROBE_FINDING}${k.texte}${said}` }));
74
+ if (gated || !claimed.length)
75
+ return out;
76
+ l.warnings.push('sonde de cas limites : échecs annoncés par la session, non vérifié par le programme (orchestrate.test absent)');
77
+ return [...out, ...claimed.map((e) => ({ source: 'tests', gravite: 'majeur', texte: `${PROBE_FINDING}${e}` }))];
78
+ }
79
+ /**
80
+ * Joue la sonde (R3 à R9). Elle ne bloque jamais le lot : échec, coupure, délai ou rapport illisible sont un warning et la revue part. Seuls un quota (suspension : la reprise rejoue la sonde),
81
+ * un verrou d'acceptation violé ou un dépôt sale le rendent, comme après toute session d'écriture. Un test rouge nouveau repart en correction ; sinon le cycle reprend (bibliothécaire, revue).
82
+ */
83
+ export async function probeStep(io) {
84
+ const l = io.lot;
85
+ const done = await io.play();
86
+ if (!done)
87
+ return;
88
+ const step = l.steps[l.steps.length - 1];
89
+ const rep = done === PROBE_FAILED ? cutSession(io, step) : readSession(io, done, step);
90
+ if (rep === 'rendu')
91
+ return;
92
+ // La porte du programme après la sonde comme après toute écriture, mais seulement si elle a commité : sans nouveau commit il n'y a rien de neuf à rejouer.
93
+ const red = [];
94
+ const runs = [];
95
+ const moved = !!step.headBefore && io.head() !== step.headBefore;
96
+ if (moved)
97
+ await io.gate(red, runs);
98
+ const found = moved ? findings(l, red, (rep?.echecs ?? []).map((e) => e.trim()).filter(Boolean), !!io.config.test) : [];
99
+ if (found.length) {
100
+ // Une correction que le budget ne paie plus n'est pas sautée : l'étape fix refuse et le lot revient au lead sans revue, tests rouges (L145, cycle.ts overBudget).
101
+ if (io.pays())
102
+ l.warnings.push(`${PROBE_FINDING}${found.length} constat(s), passe de correction avant la revue`);
103
+ step.constats = found.map((k) => k.texte);
104
+ io.fix(found);
105
+ return;
106
+ }
107
+ // Sans nouveau commit, les contrôles de la porte jouée après l'écriture valent toujours pour ce HEAD : la revue les garde (sinon elle relance la suite et le build).
108
+ await io.review(moved ? runs : [...(l.checks?.runs ?? [])]);
109
+ }
110
+ /** La session n'a rien donné (échec, coupure, délai) : un warning, pas de rapport. Ce qu'elle a commité avant d'être coupée passe les mêmes contrôles qu'après un rapport (commits au journal, verrou, fichiers hors tests) ; le dépôt a déjà été remis propre. */
111
+ function cutSession(io, step) {
112
+ unplayed(io.lot, step.cause ?? 'échec');
113
+ if (!step.headBefore || io.head() === step.headBefore)
114
+ return undefined;
115
+ io.take(step, { questions: [] });
116
+ return handedBack(io, [], step.headBefore) ? 'rendu' : undefined;
117
+ }
118
+ /** Le rapport d'une session qui a répondu : questions notées, verrou et dépôt contrôlés. `'rendu'` quand le lot est rendu. */
119
+ function readSession(io, done, step) {
120
+ const rep = readReport(io.lot, done.report);
121
+ for (const q of io.take(step, rep ?? { questions: [] }))
122
+ io.lot.warnings.push(`sonde de cas limites : question sans réponse (la sonde ne bloque pas le lot) : « ${q.trim()} »`);
123
+ return handedBack(io, io.dirt(done), step.headBefore) ? 'rendu' : rep;
124
+ }
@@ -53,8 +53,17 @@ export const PRECHECK_SCHEMA = {
53
53
  properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str },
54
54
  required: ['dejaPresent', 'preuves', 'resume'],
55
55
  };
56
+ /** Sortie de la sonde de cas limites (L497) : les tests commités, et `echecs` les tests qu'elle annonce rouges (« test — borne — attendu », un par entrée). */
57
+ export const PROBE_SCHEMA = {
58
+ type: 'object',
59
+ properties: { commits: WORK_SCHEMA.properties.commits, echecs: strings, nonVerifie: strings, questions: strings, choix: strings, resume: str },
60
+ required: ['commits', 'echecs', 'nonVerifie', 'questions', 'resume'],
61
+ };
56
62
  /** Une revue de maquette rend le même rapport qu'une revue d'ergonomie (REVIEW_SCHEMA). */
57
- export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'spec' ? SPEC_SCHEMA : kind === 'accept' ? ACCEPT_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
63
+ export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'spec' ? SPEC_SCHEMA : kind === 'accept' ? ACCEPT_SCHEMA : kind === 'probe' ? PROBE_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
64
+ /** L'agent du paquet de chaque étape qui en a un, ou `undefined` pour une écriture sans agent ; les revues et la fiche n'y sont pas : `code-reviewer`. */
65
+ const STEP_AGENT = { implement: undefined, fix: undefined, probe: undefined, spec: 'spec-writer', accept: 'acceptance-writer', ux: 'ux-reviewer', maquette: 'ux-reviewer', precheck: 'precheck-reader' };
66
+ export const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
58
67
  /** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
59
68
  export function checkShape(value, schema) {
60
69
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -9,7 +9,7 @@ import { pidAlive } from '../state.js';
9
9
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
10
10
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
11
11
  export function newLot(o) {
12
- return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, outcome: null };
12
+ return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, delaisJustes: undefined, outcome: null };
13
13
  }
14
14
  /** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
15
15
  export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
@@ -1,5 +1,5 @@
1
1
  // Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
2
- import { jestTestFiles, stripAnsi } from './baseline.js';
2
+ import { failedTests, jestTestFiles, stripAnsi, testDurationMs, timeoutLimits } from './baseline.js';
3
3
  /** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
4
4
  export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
5
5
  /** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
@@ -73,15 +73,35 @@ export function replayRanFile(output, file) {
73
73
  const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
74
74
  /** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
75
75
  const REPLAYS = 2;
76
+ /**
77
+ * Parmi `names`, les tests rouges SEULS (L510) : `run` rejoue la commande `template` avec leurs fichiers, au plus `REPLAYS` fois ; un test reste s'il est nommé en échec par chaque rejeu, qui sort en échec. C'est une preuve positive : un rejeu qui échoue pour une autre raison
78
+ * (build cassé, fichier introuvable) ne nomme pas le test et ne l'excuse pas. Un test sans fichier désignable n'est pas rejoué, donc n'est jamais retenu. Sert à savoir si un rouge du lot était déjà rouge au sha de départ.
79
+ */
80
+ export async function redAlone(run, template, names, output) {
81
+ const jest = jestTestFiles(output);
82
+ const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
83
+ let still = [...new Set(names)].filter((n) => fileOf(n));
84
+ for (let i = 0; i < REPLAYS && still.length; i++) {
85
+ const files = [...new Set(still.map((n) => fileOf(n)))];
86
+ const r = await run(template.replaceAll('{files}', () => files.map(shellArg).join(' ')));
87
+ const failing = r.code === 0 ? new Set() : new Set(failedTests(r.output));
88
+ still = still.filter((n) => failing.has(n));
89
+ }
90
+ return still;
91
+ }
92
+ /** Un test vert seul mais à au moins cette part de son délai est un « délai trop juste » (L510) : 25 s pour 30 s le 31-10 (warhammer L61). */
93
+ const TIGHT_RATIO = 0.8;
76
94
  /**
77
95
  * Rejoue SEULS les tests `names` rouges dans `output` (L354, L387) : `run` exécute la commande `template` où `{files}` devient les fichiers de ces tests, dans le dossier de l'appelant (worktree du lot, de la ligne de base ou de `verify --gate`).
78
- * Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
96
+ * Un test vert à un rejeu est `unstable` (et `tight` quand sa durée seul est près de son délai, L510) ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
79
97
  */
80
98
  export async function replayRed(run, template, names, output) {
81
99
  const jest = jestTestFiles(output);
82
100
  const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
83
- let red = names;
101
+ let red = [...new Set(names)];
84
102
  const unstable = [];
103
+ const tight = [];
104
+ const limits = timeoutLimits(output);
85
105
  for (let i = 0; i < REPLAYS; i++) {
86
106
  const replayable = red.filter((n) => fileOf(n));
87
107
  const files = [...new Set(replayable.map((n) => fileOf(n)))];
@@ -92,7 +112,14 @@ export async function replayRed(run, template, names, output) {
92
112
  const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
93
113
  const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
94
114
  unstable.push(...passed);
115
+ // L510 : vert seul mais expiré dans la suite, et la durée du rejeu est près du délai : le test est trop juste, pas bugué. Sans délai lu dans la suite ou sans durée lisible au rejeu, rien n'est dit.
116
+ for (const n of passed) {
117
+ const limit = limits.get(n);
118
+ const ms = limit ? testDurationMs(r.output, n, fileOf(n)) : null;
119
+ if (limit && ms !== null && ms >= TIGHT_RATIO * limit)
120
+ tight.push({ test: n, ms, limit });
121
+ }
95
122
  red = red.filter((n) => !passed.includes(n));
96
123
  }
97
- return { red, unstable };
124
+ return { red, unstable, tight };
98
125
  }
package/dist/perf.js CHANGED
@@ -48,7 +48,7 @@ function planLot(plans, project, lot) {
48
48
  return lots.find((l) => l.id === lot) ?? (lot.includes('/') ? lots.find((l) => l.id === lot.slice(0, lot.indexOf('/'))) : undefined);
49
49
  }
50
50
  /** Rang d'affichage d'une sorte d'étape : l'ordre du cycle, les sortes inconnues après. */
51
- const KIND_ORDER = ['spec', 'maquette', 'fiche', 'precheck', 'implement', 'review', 'review-small', 'ux', 'fix', 'accept'];
51
+ const KIND_ORDER = ['spec', 'maquette', 'fiche', 'precheck', 'implement', 'probe', 'review', 'review-small', 'ux', 'fix', 'accept'];
52
52
  const kindRank = (kind) => (KIND_ORDER.includes(kind) ? KIND_ORDER.indexOf(kind) : KIND_ORDER.length);
53
53
  /** Part de `counted` rangée en entrée quand une étape ne porte que ce compteur (journal ancien) : le reste est en sortie (R8 b). */
54
54
  const COUNTED_INPUT_SHARE = 0.9;
@@ -21,6 +21,8 @@ export const PLAN_ACTIONS = {
21
21
  /** `raf archive` (L396) : le nombre de lots déplacés dans l'archive. */
22
22
  archived: (n) => `${n} lot(s) archivé(s)`,
23
23
  reviewRecorded: 'revue de code enregistrée',
24
+ /** `raf ux <lot>` ne commite pas : l'action de la commande de commit qu'il propose (L509). */
25
+ uxRecorded: 'verdict UX enregistré',
24
26
  reviewNoted: 'revue de code notée',
25
27
  /** `cadence deliver` pendant une vague (L388) : le lot de l'article ouvert, ou ses titres ajoutés en note. */
26
28
  articleOpened: 'article à rafraîchir ouvert',
@@ -51,12 +53,12 @@ export function planCommitProblem(template) {
51
53
  }
52
54
  /**
53
55
  * Le message d'un commit de plan : le gabarit du projet (`plan.commit`), à défaut celui d'avant L343.
54
- * Hors vague, un groupe entre parenthèses qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) ».
56
+ * Hors vague, un groupe entre parenthèses ou crochets qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) » ou « [] ».
55
57
  */
56
58
  export function planCommitMessage(template, v) {
57
59
  let t = template ?? (v.wave === undefined ? PLAN_COMMIT_PLAIN : PLAN_COMMIT_WAVE);
58
60
  if (v.wave === undefined)
59
- t = t.replace(/\s*\([^()]*\{vague\}[^()]*\)/g, '').replaceAll('{vague}', '');
61
+ t = t.replace(/\s*(?:\([^()]*\{vague\}[^()]*\)|\[[^\[\]]*\{vague\}[^\[\]]*\])/g, '').replaceAll('{vague}', '');
60
62
  return t.replaceAll('{lot}', () => v.lot).replaceAll('{vague}', () => v.wave ?? '').replaceAll('{action}', () => v.action);
61
63
  }
62
64
  /** Le hook commit-msg que git lancerait dans `root` (core.hooksPath compris, husky aussi) ; null s'il n'y en a pas d'exécutable. */
@@ -0,0 +1,26 @@
1
+ import { relative } from 'node:path';
2
+ import { dirtyPaths, gitRoot } from './git.js';
3
+ import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
4
+ /**
5
+ * `raf done`, `raf ux <lot>` et `raf review <lot>` (L509) écrivent le plan et ne le commitent jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
6
+ * que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
7
+ */
8
+ function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
9
+ const repo = gitRoot(root);
10
+ if (!repo)
11
+ return null;
12
+ // Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
13
+ const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
14
+ if (paths.length === 0)
15
+ return null;
16
+ const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
17
+ const message = planCommitMessage(template, { lot, action });
18
+ const list = paths.map(quote).join(' ');
19
+ return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
20
+ }
21
+ /** Dit sur `out` la commande de commit du plan, quand il y a quelque chose à commiter (rien sinon). */
22
+ export function printUncommittedPlan(out, ...args) {
23
+ const command = uncommittedPlanCommand(...args);
24
+ if (command)
25
+ out(command);
26
+ }
package/dist/release.js CHANGED
@@ -200,20 +200,22 @@ function whatsNewIssues(text, version) {
200
200
  * types que Claude Code génère dans plugins/cadence-hud et que seul un clone ne porte pas). `git clone` du dépôt courant ne prend que ce qui est commité,
201
201
  * `--no-hardlinks` pour ne rien partager avec lui ; le clone est retiré, vert ou rouge.
202
202
  */
203
- function cloneStep(scripts) {
203
+ function cloneStep(scripts, untag) {
204
204
  const run = scripts.map((n) => (n === 'test' ? 'npm test' : `npm run ${n}`)).join(' && ');
205
- return `d=$(mktemp -d) && git clone -q --no-hardlinks . "$d/c" && (cd "$d/c" && npm ci && ${run}); r=$?; rm -rf "$d"; test $r -eq 0 (la suite dans un git clone neuf, comme publish.yml ; rouge = pas de tag)`;
205
+ return `d=$(mktemp -d) && git clone -q --no-hardlinks . "$d/c" && (cd "$d/c" && npm ci && ${run}); r=$?; rm -rf "$d"; test $r -eq 0 || ${untag} (la suite dans un git clone neuf, qui reçoit le tag provisoire, comme publish.yml ; rouge = tag retiré)`;
206
206
  }
207
207
  /** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
208
208
  function steps(p, files, branch) {
209
209
  const tag = `v${p.version}`;
210
+ // Rouge à la porte, au clone ou à l'éval : le tag provisoire est retiré, la release n'a pas eu lieu.
211
+ const untag = `{ git tag -d ${tag}; false; }`;
210
212
  const out = [
211
213
  `git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
212
- 'cadence verify --gate --sha HEAD (la suite et le build rejoués sur le commit de version, où le CHANGELOG n\'a plus de [Unreleased] plein ; rouge = pas d\'éval ni de tag)',
213
- ...(p.cloneScripts.length ? [cloneStep(p.cloneScripts)] : []),
214
- ...(p.hasEvalScript ? ['npm run eval:plugin -- --trust-plugin (facturé ; un cas sous le seuil arrête la release)'] : []),
215
- `git tag ${tag}`,
216
- `git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
214
+ `git tag ${tag} (tag local provisoire sur le commit de version : les tests qui lisent le dernier tag, comme test/changelog.test.ts, voient la version publiée ; retiré si une des trois étapes suivantes est rouge, jamais poussé avant)`,
215
+ `cadence verify --gate --sha HEAD || ${untag} (la suite et le build rejoués sur le commit de version, où le CHANGELOG n'a plus de [Unreleased] plein ; rouge = tag retiré, pas d'éval)`,
216
+ ...(p.cloneScripts.length ? [cloneStep(p.cloneScripts, untag)] : []),
217
+ ...(p.hasEvalScript ? [`npm run eval:plugin -- --trust-plugin || ${untag} (facturé ; un cas sous le seuil arrête la release et retire le tag ; si la lecture du rapport le juge faux, reposer le tag de l’étape 2 avant le push)`] : []),
218
+ `git push origin ${branch} ${tag} (le tag provisoire devient le tag de la release ; il lance le workflow de publication)`,
217
219
  'gh run watch (jusqu\'à la fin du run de publication)',
218
220
  ...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
219
221
  ...(p.pluginName ? [