@sylad/cadence 1.8.0 → 1.10.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. package/templates/orchestrate/ux.md +3 -1
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
4
+ import { decodePng, encodePng } from './png.js';
5
+ import { sessionDeRef } from './session.js';
6
+ /** Marge (pixels de la page rendue) ajoutée de chaque côté de la zone d'un élément dans sa découpe : de quoi lire ce qui l'entoure. */
7
+ export const MARGE_DECOUPE = 24;
8
+ /** Sortie structurée du troisième regard : un verdict par numéro d'élément ; le programme ne retient que les verdicts valides. */
9
+ export const REGARD_SCHEMA = {
10
+ type: 'object',
11
+ properties: {
12
+ decisions: {
13
+ type: 'array',
14
+ items: {
15
+ type: 'object',
16
+ properties: {
17
+ numero: { type: 'integer', description: 'The number of the element in the brief.' },
18
+ verdict: { type: 'string', enum: ['confirme', 'ecarte'] },
19
+ raison: { type: 'string', description: 'One sentence: what the crop shows.' },
20
+ },
21
+ required: ['numero', 'verdict', 'raison'],
22
+ additionalProperties: false,
23
+ },
24
+ },
25
+ questions: { type: 'array', items: { type: 'string' }, description: 'What a human must settle.' },
26
+ },
27
+ required: ['decisions', 'questions'],
28
+ additionalProperties: false,
29
+ };
30
+ /** Découpe la zone d'un élément (marge comprise, bornée à la page) dans `crops/regard-<numero>.png` ; null quand la page n'est pas rendue ou que la zone est hors d'elle. */
31
+ function decouper(docDir, e, numero, pages) {
32
+ const page = join(docDir, 'pages', `page-${e.source.page}.png`);
33
+ const z = e.source.zone;
34
+ if (!existsSync(page) || !z)
35
+ return null;
36
+ if (!pages.has(e.source.page))
37
+ pages.set(e.source.page, decodePng(readFileSync(page), `page ${e.source.page}`)); // une page se décode une fois, quel que soit le nombre de ses éléments
38
+ const r = pages.get(e.source.page);
39
+ const x0 = Math.max(0, z.x - MARGE_DECOUPE);
40
+ const y0 = Math.max(0, z.y - MARGE_DECOUPE);
41
+ const x1 = Math.min(r.width, z.x + z.l + MARGE_DECOUPE);
42
+ const y1 = Math.min(r.height, z.y + z.h + MARGE_DECOUPE);
43
+ if (x1 <= x0 || y1 <= y0)
44
+ return null;
45
+ const rgba = Buffer.alloc((x1 - x0) * (y1 - y0) * 4);
46
+ for (let y = y0; y < y1; y++)
47
+ r.data.copy(rgba, (y - y0) * (x1 - x0) * 4, (y * r.width + x0) * 4, (y * r.width + x1) * 4);
48
+ const dir = join(docDir, 'crops');
49
+ mkdirSync(dir, { recursive: true });
50
+ const file = join(dir, `regard-${numero}.png`);
51
+ writeFileSync(file, encodePng(x1 - x0, y1 - y0, rgba));
52
+ return file;
53
+ }
54
+ /** Ce qu'une lecture a écrit d'un élément, sans ce qui renvoie à sa propre numérotation ni à sa confiance : le troisième regard juge ce qui est dit, pas qui le dit. */
55
+ const POSE = (e) => JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['id', 'confiance', 'cause', 'ocr', 'usage', 'ecran', 'cible', 'couvre'].includes(k))));
56
+ /**
57
+ * Le troisième regard : une session de `reference-verifier` qui ne voit que les éléments d'une seule lecture et leur découpe, et dit pour chacun
58
+ * s'il est confirmé ou écarté. Rend une décision par élément (dans l'ordre de `seuls`) ; un verdict sans raison ou inconnu, ou un numéro
59
+ * hors de la liste, vaut une absence de réponse (`undefined`) ; un numéro répété garde son premier verdict. Un silence du troisième regard n'est jamais un `lu`.
60
+ */
61
+ export async function troisiemeRegard(o) {
62
+ for (const f of existsSync(join(o.docDir, 'crops')) ? readdirSync(join(o.docDir, 'crops')) : [])
63
+ if (/^regard-\d+\.png$/.test(f))
64
+ rmSync(join(o.docDir, 'crops', f));
65
+ const cadence = `node ${join(PACKAGE_ROOT, 'bin', 'cadence.js')}`;
66
+ const pages = new Map();
67
+ const blocs = o.seuls.map((item, i) => {
68
+ const e = item.element;
69
+ const crop = decouper(o.docDir, e, i + 1, pages);
70
+ return [
71
+ `${i + 1}. ${POSE(e)}`,
72
+ ` page : ${join(o.docDir, 'pages', `page-${e.source.page}.png`)}`,
73
+ crop ? ` découpe : ${crop}` : ' pas de découpe : la page n\'est pas rendue ou la zone est hors d\'elle, regarde la page entière',
74
+ ].join('\n');
75
+ });
76
+ const brief = [
77
+ `Troisième regard sur le document de référence « ${o.slug} » : ${o.seuls.length} élément${o.seuls.length > 1 ? 's' : ''} qu'un seul des deux lecteurs a écrit${o.seuls.length > 1 ? 's' : ''}.`,
78
+ '',
79
+ `Dossier du document : ${o.docDir}`,
80
+ '',
81
+ ...blocs,
82
+ '',
83
+ `Pour chacun : confirme, ou ecarte, avec la raison. Mesurer une zone (pixels de la page rendue) : ${cadence} ref measure ${o.slug} <page> <page | x,y | x,y,l,h> --quoi couleurs|contraste|boites|texte --config ${o.configPath}`,
84
+ `Ne lis que les découpes et les pages ci-dessus ; n'écris aucun fichier.`,
85
+ ].join('\n');
86
+ const r = await sessionDeRef({ ...o, agent: 'reference-verifier', brief, schema: REGARD_SCHEMA, echec: 'la session du troisième regard a échoué' });
87
+ if (r.kind === 'quota')
88
+ return r;
89
+ const structured = r.structured;
90
+ const decisions = o.seuls.map(() => undefined);
91
+ for (const d of Array.isArray(structured?.decisions) ? structured.decisions : []) {
92
+ const i = Number(d?.numero) - 1;
93
+ const raison = typeof d?.raison === 'string' ? d.raison.trim() : '';
94
+ if (!Number.isInteger(i) || i < 0 || i >= decisions.length || decisions[i] !== undefined)
95
+ continue;
96
+ if ((d.verdict !== 'confirme' && d.verdict !== 'ecarte') || raison === '')
97
+ continue;
98
+ decisions[i] = { verdict: d.verdict, raison };
99
+ }
100
+ const questions = Array.isArray(structured?.questions) ? structured.questions.map(String) : [];
101
+ return { kind: 'ok', decisions, tokens: r.tokens, session: r.session, questions };
102
+ }
@@ -0,0 +1,34 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readOrchestrateConfig } from '../config.js';
3
+ import { journalTokens, runSession } from '../orchestrate/launch.js';
4
+ import { sumTokens } from '../orchestrate/result.js';
5
+ import { RafError } from '../plan.js';
6
+ /**
7
+ * Une session de la chaîne `ref` hors vague (mêmes sortie JSON, schéma, relance de mise en forme et relance d'une session coupée qu'orchestrate),
8
+ * avec les jetons comptés comme le budget d'orchestrate : ceux de la session coupée (son journal) s'ajoutent à ceux de la relance.
9
+ * Un échec est une `RafError` (code 2), un quota un résultat.
10
+ */
11
+ export async function sessionDeRef(o) {
12
+ const config = readOrchestrateConfig(o.configPath);
13
+ const outcome = await runSession({
14
+ kind: 'ref',
15
+ sessionId: randomUUID(),
16
+ brief: o.brief,
17
+ model: o.model,
18
+ effort: config.effort.review,
19
+ schema: o.schema,
20
+ agent: o.agent,
21
+ cwd: o.docDir,
22
+ wave: `ref-${o.slug}`,
23
+ permissionMode: config.permissionMode,
24
+ addDirs: [o.docDir],
25
+ timeoutMs: config.timeouts.work,
26
+ }, { claude: o.claude, agents: o.agents, onRelaunch: o.onRelaunch });
27
+ if (outcome.kind === 'quota')
28
+ return { kind: 'quota', message: outcome.message };
29
+ if (outcome.kind === 'failed')
30
+ throw new RafError(`ref analyse : ${o.echec} : ${outcome.cause}`);
31
+ const cut = outcome.relaunched ? journalTokens(o.claudeHome, o.docDir, outcome.relaunched.sessionId) : null;
32
+ const tokens = cut ? sumTokens(cut.tokens, outcome.result.tokens).counted : outcome.result.tokens.counted;
33
+ return { kind: 'ok', structured: outcome.result.structured, tokens, session: outcome.result.sessionId };
34
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { readReferencesConfig } from '../config.js';
5
+ import { gitRoot } from '../git.js';
6
+ import { RafError } from '../plan.js';
7
+ /**
8
+ * Dossier des documents : `references.dir` de cadence.yaml (relatif à son dossier), sinon `$XDG_DATA_HOME/cadence/references`.
9
+ * Un `references.dir` dans un dépôt Git est refusé : les documents de référence peuvent être privés, ils ne se commitent pas.
10
+ */
11
+ export function referencesDir(root, configPath, env) {
12
+ const dir = readReferencesConfig(configPath).dir;
13
+ if (dir === undefined)
14
+ return join(env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share'), 'cadence', 'references');
15
+ const abs = resolve(dirname(configPath), dir);
16
+ let probe = abs;
17
+ while (!existsSync(probe) && dirname(probe) !== probe)
18
+ probe = dirname(probe);
19
+ if (gitRoot(probe))
20
+ throw new RafError(`ref : references.dir (${abs}) est dans un dépôt Git : les documents de référence restent hors du dépôt (choisir un dossier ailleurs, ou retirer la clé pour $XDG_DATA_HOME/cadence/references)`);
21
+ return abs;
22
+ }
23
+ export const documentFile = (docDir) => join(docDir, 'document.json');
24
+ export function readDocument(docDir) {
25
+ const file = documentFile(docDir);
26
+ if (!existsSync(file))
27
+ return null;
28
+ try {
29
+ return JSON.parse(readFileSync(file, 'utf8'));
30
+ }
31
+ catch (e) {
32
+ throw new RafError(`ref : ${file} illisible : ${e.message.split('\n')[0]}`);
33
+ }
34
+ }
35
+ export function writeDocument(docDir, doc) {
36
+ mkdirSync(docDir, { recursive: true });
37
+ const tmp = `${documentFile(docDir)}.tmp`;
38
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
39
+ renameSync(tmp, documentFile(docDir));
40
+ }
@@ -0,0 +1,51 @@
1
+ import { accessSync, constants, statSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { RafError } from '../plan.js';
4
+ const POPPLER = 'sudo apt install poppler-utils';
5
+ /** Les outils du système dont `cadence ref` dépend, dans l'ordre où l'erreur les nomme. */
6
+ export const TOOLS = [
7
+ { name: 'pdftoppm', commands: ['pdftoppm'], install: POPPLER },
8
+ { name: 'pdftotext', commands: ['pdftotext'], install: POPPLER },
9
+ { name: 'pdfimages', commands: ['pdfimages'], install: POPPLER },
10
+ { name: 'pdfinfo', commands: ['pdfinfo'], install: POPPLER },
11
+ { name: 'pdftohtml', commands: ['pdftohtml'], install: POPPLER },
12
+ { name: 'magick', commands: ['magick', 'convert'], install: 'sudo apt install imagemagick' },
13
+ { name: 'tesseract', commands: ['tesseract'], install: 'sudo apt install tesseract-ocr' },
14
+ ];
15
+ function executable(file) {
16
+ try {
17
+ if (!statSync(file).isFile())
18
+ return false;
19
+ accessSync(file, constants.X_OK);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ function which(command, path) {
27
+ for (const dir of path.split(delimiter)) {
28
+ if (dir && executable(join(dir, command)))
29
+ return join(dir, command);
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * Chemin absolu de chaque outil, cherché dans le PATH de l'environnement de la commande. Un outil absent est nommé avec sa
35
+ * commande d'installation (une ligne par outil), la commande sort en 2 : rien n'est installé d'office.
36
+ */
37
+ export function requireTools(env) {
38
+ const path = env.PATH ?? '';
39
+ const found = {};
40
+ const missing = [];
41
+ for (const t of TOOLS) {
42
+ const hit = t.commands.map((c) => which(c, path)).find((p) => p !== null);
43
+ if (hit)
44
+ found[t.name] = hit;
45
+ else
46
+ missing.push(` ${t.commands.join(' ou ')} : ${t.install}`);
47
+ }
48
+ if (missing.length > 0)
49
+ throw new RafError(`ref : outil absent du PATH, à installer (rien n'est installé d'office) :\n${missing.join('\n')}`);
50
+ return found;
51
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { isOpen, RafError, RESOURCE_NAME, RESOURCES_BAD_LIST } from './plan.js';
5
+ /**
6
+ * Clé `resources:` du cadence.yaml le plus proche en remontant depuis le dossier parent de `root` : le premier fichier qui porte la clé
7
+ * (même vide) l'emporte. Aucun ne la porte : liste vide, attribuée au cadence.yaml du parent direct. Un fichier illisible ou une clé
8
+ * qui n'est pas une liste de noms valides est une `RafError` nommant le fichier, jamais une liste vide silencieuse.
9
+ */
10
+ export function readResources(root) {
11
+ const fallback = join(dirname(root), 'cadence.yaml');
12
+ for (let dir = dirname(root);; dir = dirname(dir)) {
13
+ const file = join(dir, 'cadence.yaml');
14
+ if (existsSync(file) && statSync(file).isFile()) {
15
+ let raw;
16
+ try {
17
+ raw = parse(readFileSync(file, 'utf8'));
18
+ }
19
+ catch (e) {
20
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
21
+ }
22
+ if (raw != null && typeof raw === 'object' && Object.hasOwn(raw, 'resources')) {
23
+ const list = raw.resources ?? [];
24
+ if (!Array.isArray(list) || !list.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
25
+ throw new RafError(`${file} : resources ${RESOURCES_BAD_LIST}`);
26
+ return { file, names: [...new Set(list)] };
27
+ }
28
+ }
29
+ if (dirname(dir) === dir)
30
+ return { file: fallback, names: [] };
31
+ }
32
+ }
33
+ /**
34
+ * Un écart par ressource d'un lot ouvert que le cadence.yaml des dossiers parents ne déclare pas. Le fichier n'est lu que si un lot ouvert
35
+ * porte `uses:` : un plan qui n'en porte pas donne le contrôle d'avant, quel que soit l'état du dossier parent.
36
+ */
37
+ export function resourceIssues(lots, root) {
38
+ const using = lots.filter((l) => isOpen(l.status) && l.uses?.length);
39
+ if (using.length === 0)
40
+ return [];
41
+ let declared;
42
+ try {
43
+ declared = readResources(root);
44
+ }
45
+ catch (e) {
46
+ if (e instanceof RafError)
47
+ return [{ message: e.message }];
48
+ throw e;
49
+ }
50
+ return using.flatMap((l) => l.uses.filter((n) => !declared.names.includes(n)).map((n) => ({ message: `${l.id} utilise la ressource « ${n} », non déclarée dans ${declared.file} (resources:)` })));
51
+ }
package/dist/session.js CHANGED
@@ -81,6 +81,8 @@ export function lastActivity(lot, commits) {
81
81
  const days = [commits[0]?.day, lot.started, ...lot.notes.map((n) => n.date)].filter((d) => !!d);
82
82
  return days.length ? days.reduce(maxDay) : undefined;
83
83
  }
84
+ /** « 0 commit », « 1 commit », « 3 commits » : le zéro se dit, une note de clôture sur un lot sans commit ne se lit pas comme « fini ». */
85
+ export const commitCount = (n) => `${n} commit${n > 1 ? 's' : ''}`;
84
86
  const hhmm = (d) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
85
87
  /**
86
88
  * Fenêtre de la clôture : --since s'il est donné ; sinon depuis la dernière ouverture du
@@ -114,10 +116,11 @@ export function sessionStart(ctx, opts) {
114
116
  if (doing.length === 0)
115
117
  out(' (rien)');
116
118
  for (const l of doing) {
117
- const last = lastActivity(l, all.byLot.get(l.id) ?? []);
119
+ const commits = all.byLot.get(l.id) ?? [];
120
+ const last = lastActivity(l, commits);
118
121
  const idle = last ? diffDays(last, today) : 0;
119
122
  const state = last ? `dernière activité ${last}${idle > opts.idle ? `, silencieux depuis ${idle} j` : ''}` : 'aucune activité';
120
- out(` ${l.id} ${l.title} (${state})`);
123
+ out(` ${l.id} ${l.title} (${commitCount(commits.length)}, ${state})`);
121
124
  }
122
125
  section(out, 'En attente', gateWaiting(lots, blocked).map(({ lot, text }) => `${lot.id} ${lot.title} (${text})`));
123
126
  const recent = period(ctx, opts.since);
package/dist/state.js CHANGED
@@ -156,9 +156,48 @@ export function pidAlive(pid) {
156
156
  export function appendDelivery(dir, day, sha) {
157
157
  appendFileSync(join(dir, 'deliveries.log'), `${day} ${sha}\n`);
158
158
  }
159
- /** Ajoute une livraison rouge (« jour sha raison ») à `deliveries-failed.log` ; la raison est la première ligne du message d'échec. */
160
- export function appendFailedDelivery(dir, day, sha, reason) {
161
- appendFileSync(join(dir, 'deliveries-failed.log'), `${day} ${sha} ${reason.split('\n')[0].trim()}\n`);
159
+ /** Classe d'une livraison rouge : où elle a cassé. `refus` : le script a refusé avant d'agir (code 2). */
160
+ export const FAIL_CLASSES = ['refus', 'ci', 'deploiement', 'verification'];
161
+ const TAIL_SEP = ' ⏎ ';
162
+ const TAIL_LINES = 5;
163
+ const LINE_MAX = 200;
164
+ const REASON_MAX = 300;
165
+ // eslint-disable-next-line no-control-regex
166
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
167
+ // eslint-disable-next-line no-control-regex
168
+ const CONTROL = /[\u0000-\u001f\u007f]/g;
169
+ const MASK = '***';
170
+ const SECRET_WORDS = new Set(['token', 'secret', 'password', 'passwd', 'pwd', 'pass', 'key', 'credential', 'credentials', 'auth', 'authorization', 'signature', 'sig', 'jwt', 'cookie']);
171
+ // Mots sensibles qui, collés à un autre (APIKEY, ACCESSTOKEN, GITHUBTOKEN), terminent encore un nom : pas « pass », « sig » ou « auth » (BYPASS, ...).
172
+ const SECRET_SUFFIXES = ['token', 'secret', 'password', 'passwd', 'key', 'credential', 'credentials', 'signature', 'pwd'];
173
+ // Un nom de variable ou de paramètre d'URL est sensible quand l'un de ses mots (séparés par _ - . ou une majuscule) en est un, ou finit par l'un des suffixes.
174
+ const sensitiveName = (name) => name
175
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
176
+ .split(/[_.-]/)
177
+ .some((w) => SECRET_WORDS.has(w.toLowerCase()) || SECRET_SUFFIXES.some((x) => w.toLowerCase().endsWith(x)));
178
+ const TOKEN_SHAPES = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|npm_[A-Za-z0-9]{30,})\b/g;
179
+ const BEARER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
180
+ const URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi;
181
+ const ASSIGNMENT = /([A-Za-z][A-Za-z0-9_.-]*)=("[^"]*"|'[^']*'|[^\s&"']+)/g;
182
+ /** Masque ce qui ressemble à un secret (jeton connu, Bearer, identifiants d'URL, VAR=valeur ou paramètre d'URL au nom sensible) : motifs probables, pas une garantie. */
183
+ export const maskSecrets = (line) => line
184
+ .replace(BEARER, `$1 ${MASK}`)
185
+ .replace(URL_USERINFO, `$1${MASK}@`)
186
+ .replace(TOKEN_SHAPES, MASK)
187
+ .replace(ASSIGNMENT, (m, name) => (sensitiveName(name) ? `${name}=${MASK}` : m));
188
+ const clean = (line, max) => {
189
+ const t = maskSecrets(line.replace(ANSI, '').replace(CONTROL, ' ')).replaceAll('⏎', ' ').trim();
190
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
191
+ };
192
+ /**
193
+ * Ajoute une livraison rouge à `deliveries-failed.log` : une ligne « jour sha [classe] raison ⏎ ligne ⏎ ligne ».
194
+ * La raison est la première ligne du message d'échec ; `tail` (sortie de la commande en échec) est borné à ses
195
+ * 5 dernières lignes non vides de 200 caractères, sans séquence ANSI, secrets probables masqués (raison comprise).
196
+ */
197
+ export function appendFailedDelivery(dir, day, sha, reason, cls, tail = []) {
198
+ const kept = tail.flatMap((l) => clean(l, LINE_MAX) || []).slice(-TAIL_LINES);
199
+ const head = `${day} ${sha} ${cls ? `[${cls}] ` : ''}${clean(reason.split('\n')[0], REASON_MAX)}`;
200
+ appendFileSync(join(dir, 'deliveries-failed.log'), `${[head, ...kept].join(TAIL_SEP)}\n`);
162
201
  }
163
202
  export function lastDelivery(dir) {
164
203
  const file = join(dir, 'deliveries.log');
@@ -179,15 +218,44 @@ export function readDeliveries(dir) {
179
218
  return null;
180
219
  }
181
220
  }
182
- /** Les jours de `deliveries-failed.log` (une ligne « jour sha raison » par livraison rouge), dans l'ordre du fichier ; une ligne illisible est ignorée, un fichier absent ou illisible donne aucune livraison rouge. Lecture seule : ne crée rien. */
183
- export function readFailedDeliveries(dir) {
221
+ /** Classe d'une ligne écrite avant la classe [cls] (L282), déduite du libellé que `deliver` écrit lui-même ; null si le libellé n'est pas l'un des siens. */
222
+ function classFromReason(reason) {
223
+ const script = /^script de livraison en échec \((?:code (\d+)|[^)]*)\)/.exec(reason);
224
+ if (script)
225
+ return script[1] === '2' ? 'refus' : 'deploiement';
226
+ if (/^(?:déploiement en échec|CADENCE_DEPLOYED_FILE )/.test(reason))
227
+ return 'deploiement';
228
+ if (/^(?:CI en échec|CI non terminée|gh run list en échec|aucun run CI pour ce sha)/.test(reason))
229
+ return 'ci';
230
+ if (/^vérification en échec/.test(reason))
231
+ return 'verification';
232
+ return null;
233
+ }
234
+ /** Les livraisons rouges de `deliveries-failed.log`, dans l'ordre du fichier ; une ligne illisible est ignorée, un fichier absent ou illisible donne aucune livraison rouge. Lecture seule : ne crée rien. */
235
+ export function readFailedEntries(dir) {
184
236
  try {
185
237
  return readFileSync(join(dir, 'deliveries-failed.log'), 'utf8')
186
238
  .split('\n')
187
- .map((l) => l.split(' ')[0])
188
- .filter(isDay);
239
+ .flatMap((line) => {
240
+ const [head = '', ...tail] = line.split(TAIL_SEP);
241
+ const m = /^(\S+)(?: (\S+))?(?: (.*))?$/.exec(head);
242
+ if (!m || !isDay(m[1]))
243
+ return [];
244
+ let reason = m[3] ?? '';
245
+ const c = /^\[(\w+)\] ?/.exec(reason);
246
+ const written = c && FAIL_CLASSES.includes(c[1]) ? c[1] : null;
247
+ if (written)
248
+ reason = reason.slice(c[0].length);
249
+ // Sans [cls] écrit : le fichier n'est jamais réécrit, la classe se déduit du libellé à la lecture.
250
+ const cls = written ?? (c ? null : classFromReason(reason));
251
+ return [{ day: m[1], sha: m[2] ?? '', cls, reason, tail }];
252
+ });
189
253
  }
190
254
  catch {
191
255
  return [];
192
256
  }
193
257
  }
258
+ /** Les jours de `deliveries-failed.log` (une ligne par livraison rouge), dans l'ordre du fichier. */
259
+ export function readFailedDeliveries(dir) {
260
+ return readFailedEntries(dir).map((e) => e.day);
261
+ }
@@ -0,0 +1,15 @@
1
+ import { docSyncSet } from './docsync.js';
2
+ export const DEFAULT_TEST_PATTERNS = ['**/*.test.*', '**/*.spec.*', 'test/**', 'tests/**', '**/__tests__/**'];
3
+ /**
4
+ * Les fichiers de code que le lot a modifiés sans toucher le moindre fichier de test (triés), ou rien : un test touché, ou
5
+ * moins de `threshold` fichiers de code. Un fichier de test n'est jamais du code, même quand il correspond à `code`.
6
+ */
7
+ export function testGateMissing(gate, files) {
8
+ const isTest = docSyncSet(gate.tests);
9
+ if ([...files].some(isTest))
10
+ return [];
11
+ const isCode = docSyncSet(gate.code);
12
+ const isException = gate.except.length ? docSyncSet(gate.except) : () => false;
13
+ const code = [...files].filter((f) => isCode(f) && !isException(f)).sort();
14
+ return code.length >= gate.threshold ? code : [];
15
+ }
@@ -27,6 +27,18 @@ ol ▮▮▮▮▮▯▯▯▯▯ 39/78 · 0 en cours · +3 cette semaine
27
27
  cadence ▮▮▮▮▮▯▯▯▯▯ 10/20 · 2 en cours : L149 L153
28
28
  ```
29
29
 
30
+ The **sessions of the lead** (L276) — the ones started by hand and declared with `cadence lead hold <label>` — are drawn
31
+ under the waves, even when no wave is running: the slots line of `cadence orchestrate --status` and one line per session
32
+ (label, age, time left):
33
+
34
+ ```
35
+ sessions du lead : 1 · créneaux libres : 2 sur 3
36
+ correctif-L12 depuis 4 min · reste 56 min
37
+ ```
38
+
39
+ With a live wave and no held session the line reads `créneaux libres : 1 sur 2`; with neither, nothing. A session whose
40
+ time limit has run out stops counting in the slots but is not dropped (L278): a row `⚠ session du lead échue : correctif-B28 échue depuis 2 h 10` stays under the others (in red) until `cadence lead release <label>` or a renewed `lead hold`, since nothing stops the session itself. They are read from disk (`holds/` of the cadence home), like the waves.
41
+
30
42
  The bar is lots done over lots done + in progress + to do (dropped and recurring lots left out); the count of lots in progress is followed by their ids (`2 en cours : L149 L153`), cut to three then `…` (`5 en cours : L1 L2 L3 …`), the count alone at zero;
31
43
  `+N cette semaine` counts the lots created in the last 7 days (today and the six before) and is left out at zero. The source is
32
44
  `cadence lead tour <folder> --json` (the folder a wave was launched from, otherwise the session's: a folder of projects gives one line per project, a project with no sub-project gives its own single line),
@@ -38,5 +38,5 @@
38
38
  <line x1="981" y1="127" x2="981" y2="68" stroke="#58a6ff" stroke-width="1.2" stroke-dasharray="3 3" marker-end="url(#a)"/>
39
39
  <text x="73" y="216" font-size="11.5" fill="#8b949e">dashed: only for a lot with a page or a behaviour to pin down (screen: page, spec: true)</text>
40
40
  <text x="661" y="216" font-size="11.5" fill="#8b949e">each step in a fresh session, driven by cadence orchestrate</text>
41
- <text x="20" y="236" font-size="11.5" fill="#8b949e">Every verdict is written to the plan (docs/plan/raf.yaml); a delivery counts only when its checks pass.</text>
41
+ <text x="20" y="236" font-size="11.5" fill="#8b949e">Every verdict is written to the plan (.cadence/raf.yaml); a delivery counts only when its checks pass.</text>
42
42
  </svg>
@@ -38,5 +38,5 @@
38
38
  <line x1="981" y1="127" x2="981" y2="68" stroke="#0969da" stroke-width="1.2" stroke-dasharray="3 3" marker-end="url(#a)"/>
39
39
  <text x="73" y="216" font-size="11.5" fill="#57606a">dashed: only for a lot with a page or a behaviour to pin down (screen: page, spec: true)</text>
40
40
  <text x="661" y="216" font-size="11.5" fill="#57606a">each step in a fresh session, driven by cadence orchestrate</text>
41
- <text x="20" y="236" font-size="11.5" fill="#57606a">Every verdict is written to the plan (docs/plan/raf.yaml); a delivery counts only when its checks pass.</text>
41
+ <text x="20" y="236" font-size="11.5" fill="#57606a">Every verdict is written to the plan (.cadence/raf.yaml); a delivery counts only when its checks pass.</text>
42
42
  </svg>
package/docs/deliver.md CHANGED
@@ -43,6 +43,34 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
43
43
  CI can only build what was pushed); no other delivery running (a lock whose
44
44
  process died is removed with a warning); with `ci: github`, `gh` installed and
45
45
  logged in.
46
+ - **Red delivery log** (L282): a failure after the lock is taken appends one line to
47
+ `<git common dir>/cadence/deliveries-failed.log`: `day sha [class] reason ⏎ line ⏎ line`.
48
+ The class says where it broke: `refus` (the project script exited with code 2 — the
49
+ convention of `deliver` itself for « refused before acting »), `ci`, `deploiement`
50
+ (the project script with any other code, a deploy command, an unusable
51
+ `CADENCE_DEPLOYED_FILE`) or `verification`. The reason is the first line of the failure
52
+ message, followed by the **last 5 non-empty lines** (200 characters each, ANSI sequences
53
+ removed) of the output of the command that failed: the project script, the CI command, the
54
+ deploy command or the check command. A GitHub CI run or an URL check has no output of its
55
+ own, so no line follows. cadence now relays the output of those commands through itself
56
+ (same bytes on its own stdout and stderr, but the command no longer sees a terminal there,
57
+ so it may drop colours or progress bars). A line rewritten with `\r` (a progress bar that
58
+ did not drop) keeps only its last non-empty frame, not 5 frames of the bar. **Probable
59
+ secrets are masked** (`***`) in the reason and in those lines before the file is written:
60
+ known token shapes (GitHub, GitLab, npm, Slack, AWS key id, `sk-…`), `Bearer`/`Basic`
61
+ values, `user:password@` in a URL, and any `NAME=value` (shell variable or URL query
62
+ parameter) whose name has a word such as token, secret, password, key, auth, signature, or
63
+ ends with one of token, secret, password, pwd, key, credential, signature (`APIKEY`,
64
+ `ACCESSTOKEN`, `GITHUBTOKEN`, `MYSQLPWD`) (L294). The 8192 characters kept are the END of the
65
+ output (`OUTPUT_KEPT`, `src/deliver.ts`), so the bound cannot cut the end of a value; it can cut
66
+ its beginning (the name and the opening quote gone), and that case is **not handled** — the log
67
+ stays local. A quoted value never closed is not treated either (L294 tried, and each fix opened a
68
+ new leak): only closed values and unquoted values are matched.
69
+ This is a list of likely patterns, not a guarantee: a secret of another shape printed by a
70
+ command can still land in the log, which stays local (`<git common dir>/cadence/`). `cadence perf` reads classes and projects from it
71
+ ([perf](perf.md#delivery-quality-and-the-tour-header-l227)); a line written before L282
72
+ has no `[class]`: it is read by its wording (the one `deliver` writes itself, see
73
+ [perf](perf.md#delivery-quality-and-the-tour-header-l227)) and counts as « non classée » only when the wording is not recognised. The file is never rewritten.
46
74
  - Every command is killed when it exceeds its budget (CI, `deployTimeout`, what is
47
75
  left of `verifyTimeout`) and reported as "délai dépassé". Killed means the
48
76
  whole tree, followed while the command runs: every 200 ms cadence lists the
@@ -65,6 +93,20 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
65
93
  is still failing when the kill comes (the last listing is not trusted after
66
94
  1 second): only the command is killed, its descendants survive, and cadence
67
95
  says so once on stderr.
96
+ The 2 seconds are a constant of the real dependencies (`realDeps`, the `graceMs`
97
+ argument), injectable so that the tests measure the kill, not the load of the machine.
98
+ A Ctrl-C goes to the whole foreground group, so the command can die and its
99
+ exit be processed before the signal addressed to cadence: while followed
100
+ processes are still alive after the command exits, or the command itself died
101
+ of a termination signal (SIGINT, SIGTERM, SIGHUP, or exited 128 plus one of
102
+ them: a `curl` killed by the Ctrl-C), cadence waits up to
103
+ 2 seconds more (`SIGNAL_PENDING_MS`, the `pendingMs` argument) for that signal,
104
+ and plays the grace above if it comes. Without it a background child that
105
+ ignores Ctrl-C would survive, and a second Ctrl-C would kill cadence without
106
+ releasing the lock. A command that leaves a daemon behind, or that exits 130
107
+ without any signal coming, therefore returns up to 2 seconds later. The termination listener is installed before
108
+ the command is spawned, so a Ctrl-C received while cadence first reads the
109
+ process list is handled too, not killed by the default action.
68
110
  A delay overrun and `SIGTERM` give no grace: a script's own `trap` does not
69
111
  get to finish there. The lock is held until the followed set is empty: when
70
112
  cadence dies of the signal it dies after the tree, and its stale lock, which
@@ -73,8 +115,9 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
73
115
  a process that leaves the tree before cadence first sees it (a daemon's
74
116
  double fork, `setsid`, `nohup … &` from a shell that exits, all within less
75
117
  than 200 ms), a process of another user that cadence may not signal (`sudo`),
76
- a background process still running when the command returns normally (it is
77
- neither waited for nor killed), and everything if cadence itself is killed
118
+ a background process still running when the command returns normally (cadence
119
+ waits up to 2 seconds for a signal on its way, see above, then carries on
120
+ without killing it), and everything if cadence itself is killed
78
121
  with `SIGKILL` (`kill -9`, the OOM killer).
79
122
  Where the process list cannot be read (no `/proc` and `ps` failing), cadence
80
123
  can only kill the root command (`sh`): it leads no group of its own, since it
@@ -90,7 +133,7 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
90
133
  5 minutes is a failure (you probably pushed another commit than the one you
91
134
  deliver); every run must end `success`, `skipped` or `neutral`. `gh` errors
92
135
  are retried, and reported with their cause after 5 minutes.
93
- - Commands get `CADENCE_SHA`, `CADENCE_SHORT` (7 characters) and `CADENCE_BRANCH`;
136
+ - Commands get `CADENCE_SHA`, `CADENCE_SHORT` (7 characters) and `CADENCE_BRANCH` (a `deliver.script` also gets `CADENCE_DEPLOYED_FILE`, see below);
94
137
  `${SHA}` and `${SHORT}` are replaced in `url` and `contains`.
95
138
  - On success the lots cited by the commits since the previous delivery are
96
139
  listed, so you can `raf done` those whose effect you have seen. With a
@@ -124,13 +167,41 @@ cadence deliver --sha 6b0d9aa -- api # an earli
124
167
  `--sha` (any mode) delivers a pushed commit other than `HEAD` — for a CI that
125
168
  builds each service only on the commit that touched it. `allowDirty` suits a
126
169
  working tree shared by several sessions when the delivery starts from a pushed
127
- sha and never from local files; without it a modified tree is refused.
170
+ sha and never from local files; without it a modified tree is refused. `--sha`
171
+ itself never reads the working tree: a tracked file modified outside the delivered
172
+ sha (the plan after `raf done`, say) neither refuses nor is reported, with or
173
+ without `allowDirty`.
174
+
175
+ **While a wave runs.** `cadence orchestrate` holds the repository: a plain `cadence deliver` is refused until every lot of the
176
+ repository in the wave is ready or handed back. `--sha` is the exception (L277): it is accepted while the wave runs when no
177
+ commit in the history of `<sha>` belongs to a lot of the wave that is not ready (running, queued, in question, suspended,
178
+ handed back, failed), that is when `<sha>` is « livrable jusqu'à » (the line the wave's final table prints). A ready lot is
179
+ then delivered while the wave runs, without waiting for the slowest lot. The refusal names the lots that hold the sha back. A lot's commits
180
+ are what git attributes to it in the repository (the commits that cite it) **and** what its sessions' reports noted, for the project's own
181
+ repository as for a neighbour one (the lot's `repos:` key): git sees at once the commit of a session still running, cut by the quota or failed,
182
+ which the reports only note when the session ends validly. A lot of the wave with no commit yet blocks nothing. What cannot be verified is
183
+ refused: an unreadable wave state, an unknown sha, or a lot that is not ready whose commits cannot be read (its project's plan is unreadable or
184
+ does not know the lot) while none of the commits already known holds the sha back. Push the sha alone first (`git push origin <sha>:main`).
185
+
186
+ **What is logged as delivered.** cadence no longer guesses which commit a project script deployed. Before running `deliver.script` it creates an
187
+ empty file and hands its path to the script in `CADENCE_DEPLOYED_FILE` (next to `CADENCE_SHA`, `CADENCE_SHORT`, `CADENCE_BRANCH`). A script
188
+ that deploys a commit other than `CADENCE_SHA` (it committed a `--news` timestamp itself, say) writes that sha into the file. After the script:
189
+
190
+ - the file holds a hexadecimal sha (7 to 40 characters) that resolves to a commit: that is the sha logged, and `✓ livré :` names it (« sha déclaré par le script ») — it wins over
191
+ HEAD, during a wave or not;
192
+ - the file holds anything else (not a commit, several lines, `HEAD` or a branch name — a revision would be resolved after the script, possibly at another lot's commit, so only a sha is accepted): the delivery fails with a message that says so, nothing is logged;
193
+ - the file is empty and the delivery runs **while a wave runs**: the log keeps `<sha>`, and if HEAD moved during the delivery a warning says
194
+ so (« la tête a avancé pendant la livraison ; sans sha déclaré par le script (CADENCE_DEPLOYED_FILE), le journal garde <sha> ») — the new
195
+ commit may be the script's, a running lot's, or a plan commit, and cadence cannot tell them apart;
196
+ - the file is empty and **no wave runs**: the historical rule stays, a script that committed and pushed during the delivery has delivered the new
197
+ HEAD, which is logged (« head moved by the script »).
128
198
 
129
199
  cadence keeps what the script usually lacks: the preconditions (clean tree, pushed
130
200
  `HEAD`), the lock (never two deliveries at once), the delivery log and the lots
131
201
  delivered. Arguments are quoted for `sh`, so spaces and quotes reach the script
132
202
  intact. If the script commits and pushes during the delivery (stamping a
133
- changelog entry, say), the new `HEAD` is the sha recorded as delivered. Exit code
203
+ changelog entry, say), outside a wave the new `HEAD` is the sha recorded as delivered; during a wave, or to be exact
204
+ about it anywhere, the script declares it in `CADENCE_DEPLOYED_FILE` (above). Exit code
134
205
  0 of the script means delivered; `verify` checks, if any, run after it.
135
206
 
136
207
  ## verify: replay the effect checks, outside a delivery
@@ -1,6 +1,6 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1320 350" width="1320" height="350" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#e6edf3">
2
2
  <title>A lot inside an orchestrate wave</title>
3
- <desc>A lot without commits starts with a precheck (unless orchestrate.precheck is false), then by type through spec, mockup or sheet review and sealed acceptance tests, or straight to the implementation; then the code review, corrections (two passes, a third if the review converges), the UX pass if visible, once, after the compliant code review, and an automatic raf review.</desc>
3
+ <desc>A lot without commits whose title cites a finding or an earlier lot starts with a precheck (unless orchestrate.precheck is false), then by type through spec, mockup or sheet review and sealed acceptance tests, or straight to the implementation; then the code review, corrections (two passes, a third if the review converges), the UX pass if visible, once, after the compliant code review, and an automatic raf review.</desc>
4
4
  <defs><marker id="arr2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/></marker></defs>
5
5
  <rect width="1320" height="350" fill="#0d1117"/>
6
6
  <rect x="20" y="140" width="120" height="52" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
@@ -1,6 +1,6 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1320 350" width="1320" height="350" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#1f2328">
2
2
  <title>A lot inside an orchestrate wave</title>
3
- <desc>A lot without commits starts with a precheck (unless orchestrate.precheck is false), then by type through spec, mockup or sheet review and sealed acceptance tests, or straight to the implementation; then the code review, corrections (two passes, a third if the review converges), the UX pass if visible, once, after the compliant code review, and an automatic raf review.</desc>
3
+ <desc>A lot without commits whose title cites a finding or an earlier lot starts with a precheck (unless orchestrate.precheck is false), then by type through spec, mockup or sheet review and sealed acceptance tests, or straight to the implementation; then the code review, corrections (two passes, a third if the review converges), the UX pass if visible, once, after the compliant code review, and an automatic raf review.</desc>
4
4
  <defs><marker id="arr2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#57606a"/></marker></defs>
5
5
  <rect width="1320" height="350" fill="#ffffff"/>
6
6
  <rect x="20" y="140" width="120" height="52" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5" stroke-dasharray="6 4"/>