@sylad/cadence 1.7.0 → 1.9.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { parse } from 'yaml';
4
+ import { isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from 'yaml';
5
5
  import { acceptationFile } from './accept.js';
6
6
  import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
7
7
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -75,13 +75,97 @@ export function ficheReady(repo, fiche, lot, parts = { screen: true, back: false
75
75
  return false;
76
76
  }
77
77
  }
78
- /** Consigne de reprise après la réponse du lead à des hypothèses coûteuses : la règle confirmée n'est plus une hypothèse. */
79
- export function answeredText(asked, answer) {
78
+ /** Début de la ligne qui contient l'offset. */
79
+ const lineStart = (text, at) => text.lastIndexOf('\n', at - 1) + 1;
80
+ /** Fin de la ligne qui contient l'offset, saut de ligne compris (ou la fin du texte). */
81
+ const lineEnd = (text, at) => {
82
+ if (at > 0 && text[at - 1] === '\n')
83
+ return at; // un scalaire bloc finit déjà en début de ligne
84
+ const nl = text.indexOf('\n', at);
85
+ return nl === -1 ? text.length : nl + 1;
86
+ };
87
+ /**
88
+ * Passe en `sourcee` les règles `hypothese` nommées (L297) : `source` dit d'où vient la réponse, `si_faux` disparaît. Seules les lignes `statut`,
89
+ * `source` et `si_faux` de ces règles sont éditées dans le texte (L302) : le reste de la fiche — ordre, commentaires, style de flux `[R2]` — est conservé
90
+ * à l'octet près, ce qu'une réécriture du document (`doc.toString`) ne garantit pas. Une règle écrite en style de flux (`{id: R1, …}`) n'est pas éditée : erreur.
91
+ * Rend les ids effectivement changés ; une règle absente ou déjà sourcée n'est pas touchée.
92
+ */
93
+ export function sourceRules(repo, fiche, ids, source) {
94
+ const file = join(repo, fiche);
95
+ const text = readFileSync(file, 'utf8');
96
+ const regles = parseDocument(text).get('regles');
97
+ if (!isSeq(regles))
98
+ return [];
99
+ const changed = [];
100
+ const edits = [];
101
+ const sourceValue = stringify(source).trimEnd();
102
+ for (const r of regles.items) {
103
+ if (!isMap(r))
104
+ continue;
105
+ const id = String(r.get('id'));
106
+ if (!ids.includes(id) || r.get('statut') !== 'hypothese')
107
+ continue;
108
+ if (r.flow)
109
+ throw new Error(`${id} est écrite en style de flux (${fiche}) : à passer en sourcée à la main`);
110
+ const pair = (key) => r.items.find((p) => isScalar(p.key) && p.key.value === key);
111
+ const statut = pair('statut');
112
+ const valueEnd = (p) => (isNode(p.value) && p.value.range ? p.value.range[1] : p.key.range[1]);
113
+ if (!statut || !isNode(statut.value) || !statut.value.range || !isNode(statut.key) || !statut.key.range)
114
+ continue;
115
+ edits.push({ start: statut.value.range[0], end: statut.value.range[1], text: 'sourcee' });
116
+ const src = pair('source');
117
+ if (src && isNode(src.value) && src.value.range)
118
+ edits.push({ start: src.value.range[0], end: src.value.range[1], text: sourceValue });
119
+ else {
120
+ const at = lineEnd(text, valueEnd(statut));
121
+ const indent = ' '.repeat(statut.key.range[0] - lineStart(text, statut.key.range[0]));
122
+ edits.push({ start: at, end: at, text: `${at > 0 && text[at - 1] !== '\n' ? '\n' : ''}${indent}source: ${sourceValue}\n` });
123
+ }
124
+ const siFaux = pair('si_faux');
125
+ if (siFaux && isNode(siFaux.key) && siFaux.key.range)
126
+ edits.push({ start: lineStart(text, siFaux.key.range[0]), end: lineEnd(text, valueEnd(siFaux)), text: '' });
127
+ changed.push(id);
128
+ }
129
+ if (!changed.length)
130
+ return changed;
131
+ let out = text;
132
+ for (const e of edits.sort((x, y) => y.start - x.start || y.end - x.end))
133
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
134
+ writeFileSync(file, out);
135
+ return changed;
136
+ }
137
+ /**
138
+ * La réponse du lead corrige-t-elle la règle (la supprime, la change, la remplace) au lieu de la confirmer ? Une lecture prudente (L302) : au moindre
139
+ * mot de correction ou de refus, la règle n'est pas passée en `sourcee` telle quelle, la session spec reprise l'applique. Se tromper dans ce sens
140
+ * ne coûte qu'une session ; l'autre sens source une règle que le lead vient de refuser.
141
+ */
142
+ export function answerAltersRule(answer) {
143
+ return /(?<![\p{L}])(non|no|pas|supprim\p{L}*|retir\p{L}*|enl[eè]v\p{L}*|abandonn\p{L}*|chang\p{L}*|modifi\p{L}*|remplac\p{L}*|corrig\p{L}*|plut[oô]t|à la place|sauf|remove|delet\p{L}*|drop|change|replace\p{L}*|instead|rather|except)(?![\p{L}])/iu.test(answer);
144
+ }
145
+ /** Les règles nommées que la fiche donne déjà pour sourcées par la réponse du lead (ce que `sourceRules` a posé). */
146
+ export function ruleIdsSourcedByLead(repo, fiche, ids) {
147
+ try {
148
+ const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
149
+ return list(isObj(doc) ? doc.regles : []).filter((r) => ids.includes(String(r.id)) && r.statut === 'sourcee' && String(r.source).startsWith(LEAD_SOURCE)).map((r) => String(r.id));
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ /** Début de la source d'une règle tranchée par le lead : `réponse du lead, <date>, vague <id>`. */
156
+ export const LEAD_SOURCE = 'réponse du lead';
157
+ /**
158
+ * Consigne de reprise après la réponse du lead à des hypothèses coûteuses. Les règles que le programme a déjà passées en `sourcee` (`sourced`) ne sont plus à
159
+ * réécrire que si le lead les a corrigées ; les autres, l'agent les passe lui-même.
160
+ */
161
+ export function answeredText(asked, answer, sourced = []) {
80
162
  return [
81
163
  'The program asked the lead about expensive-to-undo hypotheses of your sheet:',
82
164
  ...asked.map((q) => `- ${q}`),
83
165
  `Answer from the lead: ${answer}`,
84
- 'Apply it to the sheet: a rule the lead confirms becomes `statut: sourcee` with `source: réponse du lead` (no `si_faux`); a rule the lead corrects is rewritten as they say (`sourcee`, same source). Do not leave it as `hypothese`, or the program asks the same question again.',
166
+ sourced.length
167
+ ? `The program already set ${sourced.join(', ')} to \`statut: sourcee\` with the lead's answer as its \`source\` (no \`si_faux\`). Touch the sheet only if the lead corrected a rule: rewrite its \`enonce\` as they say and keep the \`sourcee\` status and the \`source\`. Do not turn it back into a \`hypothese\`.`
168
+ : 'Apply it to the sheet: a rule the lead confirms becomes `statut: sourcee` with `source: réponse du lead` (no `si_faux`); a rule the lead corrects is rewritten as they say (`sourcee`, same source). Do not leave it as `hypothese`, or the program asks the same question again.',
85
169
  ].join('\n');
86
170
  }
87
171
  /** Fichiers suivis par git sous un dossier (chemins relatifs au dépôt). */
@@ -1,6 +1,10 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
2
4
  import { join, relative } from 'node:path';
3
5
  import { gitCommonDir, gitRoot } from '../git.js';
6
+ /** Début de l'avertissement d'un rapport de précheck illisible (Sonnet) : le journal le garde, `cadence perf` s'en sert pour ne pas compter ce lot « non » (L293). */
7
+ export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
4
8
  export function newLot(o) {
5
9
  return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
6
10
  }
@@ -12,6 +16,8 @@ export const lotRepoPaths = (l) => [l.repo, ...(l.repos ?? []).map((r) => r.path
12
16
  export const neighbourDirs = (l) => (l.repos ?? []).map((r) => r.path);
13
17
  export const lotKey = (project, lot) => `${project}:${lot}`;
14
18
  /** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) ou d'un projet imbriqué (`groupe/gamma`) devient « __ » ; sans « / » le nom est inchangé. */
19
+ /** Racine des TMPDIR de session : `os.tmpdir()`, ou `/tmp` quand celui-ci est trop long pour laisser 60 caractères sous la limite de 107 des sockets Unix. */
20
+ const shortTmpBase = () => (join(tmpdir(), 'cadence-xxxxxxxx').length + 60 < 108 ? tmpdir() : '/tmp');
15
21
  export const lotSlug = (project, lot) => `${project.split('/').join('__')}--${lot.split('/').join('__')}`;
16
22
  const lotFile = (project, lot) => `${lotSlug(project, lot)}.json`;
17
23
  function atomicWrite(file, data) {
@@ -80,6 +86,28 @@ export class RunStore {
80
86
  mkdirSync(d, { recursive: true });
81
87
  return d;
82
88
  }
89
+ /**
90
+ * Dossier temporaire des sessions d'un lot (L243) : `<tmpdir>/cadence-<hash court du dossier du lot>`, hors du dépôt et hors de la vague, jamais suivi par git.
91
+ * Il est COURT à dessein : Chrome (Playwright MCP) range ses sockets Unix sous `TMPDIR` et refuse au-delà de 107 caractères
92
+ * (sous `.cadence/runs/<vague>/<projet>--<lot>/tmp`, L242, la revue UX échouait « Socket path too long »).
93
+ * Un lien `<lotDir>/tmp` y renvoie, pour le retrouver depuis le dossier de la vague.
94
+ */
95
+ tmpDir(project, lot) {
96
+ const lotDir = this.lotDir(project, lot);
97
+ const d = join(shortTmpBase(), `cadence-${createHash('sha1').update(lotDir).digest('hex').slice(0, 8)}`);
98
+ mkdirSync(d, { recursive: true });
99
+ const link = join(lotDir, 'tmp');
100
+ rmSync(link, { recursive: true, force: true });
101
+ symlinkSync(d, link);
102
+ return d;
103
+ }
104
+ /** Supprime le dossier temporaire du lot et son lien (fin de lot) ; sans effet s'ils n'existent pas. */
105
+ removeTmpDir(project, lot) {
106
+ const link = join(this.lotDir(project, lot), 'tmp');
107
+ if (lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink())
108
+ rmSync(readlinkSync(link), { recursive: true, force: true });
109
+ rmSync(link, { recursive: true, force: true });
110
+ }
83
111
  writeWave(w) {
84
112
  atomicWrite(join(this.dir, 'wave.json'), w);
85
113
  }
@@ -131,17 +159,25 @@ export class RunStore {
131
159
  appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
132
160
  }
133
161
  }
134
- /** Ajoute `.cadence/` à `.git/info/exclude` quand le dossier de lancement est dans un dépôt : l'état ne salit jamais un arbre. */
162
+ /**
163
+ * Ajoute `.cadence/runs/` (états de vague) et `.cadence/corrections.json` (cache de `cadence corrections`) à `.git/info/exclude`
164
+ * quand le dossier de lancement est dans un dépôt : l'état ne salit jamais un arbre. Le reste de `.cadence/` (le plan, ses notes)
165
+ * est versionné. L'ancienne ligne `/.cadence/`, écrite par une version précédente, est remplacée : elle cacherait aussi le plan.
166
+ */
135
167
  export function excludeState(launchDir) {
136
168
  const root = gitRoot(launchDir);
137
169
  if (!root)
138
170
  return;
139
- const rel = relative(root, join(launchDir, '.cadence')).split('\\').join('/');
140
- const pattern = `/${rel}/`;
171
+ const base = `/${relative(root, join(launchDir, '.cadence')).split('\\').join('/')}/`;
172
+ const wanted = [`${base}runs/`, `${base}corrections.json`];
141
173
  const file = join(gitCommonDir(launchDir), 'info', 'exclude');
142
174
  mkdirSync(join(gitCommonDir(launchDir), 'info'), { recursive: true });
143
175
  const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
144
- if (text.split('\n').includes(pattern))
176
+ const lines = text.split('\n');
177
+ const missing = wanted.filter((w) => !lines.includes(w));
178
+ if (!lines.includes(base) && missing.length === 0)
145
179
  return;
146
- appendFileSync(file, `${text === '' || text.endsWith('\n') ? '' : '\n'}${pattern}\n`);
180
+ const kept = lines.filter((l) => l !== base);
181
+ const body = kept.join('\n');
182
+ writeFileSync(file, `${body}${body === '' || body.endsWith('\n') ? '' : '\n'}${missing.map((m) => `${m}\n`).join('')}`);
147
183
  }
@@ -1,10 +1,11 @@
1
1
  import { lotWork } from '../audit.js';
2
2
  import { planLoader } from '../config.js';
3
- import { isAncestor } from '../git.js';
3
+ import { gitRoot, isAncestor } from '../git.js';
4
+ import { repoWork } from '../repos.js';
4
5
  import { quotaReset } from './result.js';
5
6
  const k = (n) => `${Math.round(n / 1000)} k`;
6
7
  const m = (n) => `${(n / 1_000_000).toFixed(1).replace('.', ',')} M`;
7
- const LABEL = {
8
+ export const LABEL = {
8
9
  queued: 'en attente',
9
10
  implementing: 'implémentation',
10
11
  reviewing: 'revue',
@@ -93,6 +94,62 @@ function deliverable(lots, ancestor, fromGit) {
93
94
  }
94
95
  return out;
95
96
  }
97
+ /**
98
+ * Sha abrégés des commits de `l` dans `repo` que git lui attribue — dans le dépôt de son projet (commits qui citent le lot) ou, quand `repo` est
99
+ * l'un de ses dépôts voisins (clé `repos:`, L62), dans ce voisin (`repoWork` : commits qui citent le lot, dans sa période, et sa chaîne `cite:`).
100
+ * Contrairement à `gitCommitsOf`, une lecture impossible LÈVE : plan du projet illisible ou qui ne connaît pas le lot, dépôt qui n'est plus un dépôt
101
+ * git. « Aucun commit » et « commits illisibles » ne se confondent pas ici : la garde de `deliver --sha` refuse le second.
102
+ */
103
+ export function gitCommitsIn(l, repo) {
104
+ const plan = planLoader(l.repo)();
105
+ if (!plan.lots().some((x) => x.id === l.lot))
106
+ throw new Error(`le plan de ${l.repo} ne connaît pas le lot ${l.lot}`);
107
+ const neighbour = l.repo === repo ? undefined : (l.repos ?? []).find((r) => r.path === repo);
108
+ const where = neighbour ? neighbour.path : l.repo;
109
+ if (!gitRoot(where))
110
+ throw new Error(`${where} n'est pas un dépôt git`);
111
+ const work = neighbour ? repoWork(plan, neighbour, l.lot) : lotWork(plan, l.repo, l.lot);
112
+ return work.map((c) => c.sha.slice(0, 7)).reverse();
113
+ }
114
+ /** Sha des commits que les étapes de `l` ont notés dans `repo` : entrées `<sha> <sujet>` pour son dépôt, `[rel] <sha> <sujet>` pour un voisin. */
115
+ function stepCommits(l, repo) {
116
+ const entries = l.steps.flatMap((s) => s.commits ?? []);
117
+ const prefixes = l.repo === repo ? [] : (l.repos ?? []).filter((r) => r.path === repo).map((r) => `[${r.rel}] `);
118
+ if (prefixes.length === 0)
119
+ return entries.filter((c) => !c.startsWith('[')).map((c) => c.split(' ')[0]);
120
+ return entries.flatMap((c) => prefixes.filter((p) => c.startsWith(p)).map((p) => c.slice(p.length).split(' ')[0]));
121
+ }
122
+ /**
123
+ * Livrer au fil de l'eau (L277) : les lots de la vague qui empêchent de livrer `sha` dans `repo` — ceux qui ne sont pas prêts (en cours, en file,
124
+ * en question, suspendus, rendus, échoués) et dont un commit est dans l'histoire de `sha`. `blocking` vide ET `unreadable` vide : tous les commits de
125
+ * `sha` qui citent un lot de la vague citent des lots prêts, revus (« prêt » est l'état d'un lot dont la revue est conforme) ; `sha` est
126
+ * « livrable jusqu'à » de la vague. Un lot sans commit ne bloque rien.
127
+ *
128
+ * Les commits d'un lot pas prêt sont l'union de ce que git lui attribue (`read`) et de ce que ses étapes ont noté : les entrées d'étape ne sont
129
+ * écrites qu'à la fin d'une session au rapport valide, git voit tout de suite le commit d'une session en cours, coupée par le quota ou échouée,
130
+ * dans son dépôt comme dans un voisin. Un lot dont les commits n'ont pu être lus (`read` lève) et dont aucun commit déjà connu ne bloque est
131
+ * `unreadable` : on ne laisse passer que ce qu'on a pu vérifier.
132
+ */
133
+ export function blockingLots(lots, repo, sha, ancestor = isAncestor, read = gitCommitsIn) {
134
+ const guard = { blocking: [], unreadable: [] };
135
+ for (const l of lots) {
136
+ if (l.status === 'ready')
137
+ continue;
138
+ let found = [];
139
+ let readable = true;
140
+ try {
141
+ found = read(l, repo);
142
+ }
143
+ catch {
144
+ readable = false;
145
+ }
146
+ if ([...found, ...stepCommits(l, repo)].some((c) => ancestor(repo, c, sha)))
147
+ guard.blocking.push(l);
148
+ else if (!readable)
149
+ guard.unreadable.push(l);
150
+ }
151
+ return guard;
152
+ }
96
153
  /** Tests déjà rouges au départ listés un par un dans le tableau ; au-delà, un compte. */
97
154
  const BASELINE_LISTED = 30;
98
155
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
@@ -117,6 +174,8 @@ export function renderTable(wave, lots, deps = {}) {
117
174
  out.push(`${d.ready.project}:${d.ready.lot} — livrable jusqu'à ${d.sha} (dernier commit de ${d.ready.lot}, sous ${d.under.lot} ${d.under.status === 'handed-back' || d.under.status === 'failed' ? 'rendu' : LABEL[d.under.status]}) : git push origin ${d.sha}:main && cadence deliver --sha ${d.sha}`);
118
175
  }
119
176
  for (const l of lots) {
177
+ if (l.goal)
178
+ out.push(`${l.project}:${l.lot} — objectif donné : ${l.goal}`);
120
179
  if (l.uxNote)
121
180
  out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
122
181
  if (l.uxCaptures)
@@ -0,0 +1,40 @@
1
+ /** Estimate (en jours) que le délai de base `timeouts.work` couvre : un lot jusqu'à un quart de jour. */
2
+ export const BASE_ESTIMATE = 0.25;
3
+ /** Majoration d'un lot à fiche (étape spec, écran ou `spec: true`) : la session lit la fiche et écrit un test par règle. */
4
+ export const FICHE_FACTOR = 1.5;
5
+ /** Plafond du délai proportionné, en multiples du délai de base. */
6
+ export const MAX_FACTOR = 4;
7
+ /** Prolongation accordée à chaque échéance où la session a commité depuis la dernière. */
8
+ export const EXTENSION_MS = 15 * 60_000;
9
+ /** Nombre de prolongations au plus : 2 h de plus après le délai proportionné, la session qui commite sans fin finit par être coupée. */
10
+ export const MAX_EXTENSIONS = 8;
11
+ /**
12
+ * Délai d'une étape d'écriture (L298) : le délai configuré (`timeouts.implement`, 45 min par défaut) vaut pour un lot de 0,25 j ;
13
+ * il croît avec l'estimate (0,5 j : le double), d'un tiers en plus de moitié pour un lot à fiche, sans dépasser 4 fois le délai de base.
14
+ */
15
+ export function workTimeoutMs(baseMs, estimate, fiche) {
16
+ const scale = Math.max(1, (estimate ?? BASE_ESTIMATE) / BASE_ESTIMATE) * (fiche ? FICHE_FACTOR : 1);
17
+ return Math.round(Math.min(scale, MAX_FACTOR) * baseMs);
18
+ }
19
+ const isTest = (f) => /(^|\/)(tests?|__tests__)\//.test(f) || /\.(test|spec)\.[a-z]+$/.test(f);
20
+ /** Fichiers listés au plus dans le message : au-delà, le nombre seul. */
21
+ const LISTED = 8;
22
+ /**
23
+ * Phrase du tableau pour une étape d'écriture coupée à l'échéance (L298) : commits conservés, travail NON commité
24
+ * (fichiers, dont les tests, lignes). `numstat` : sortie de `git diff --numstat HEAD` ; les fichiers non suivis n'y ont pas de lignes.
25
+ */
26
+ export function cutReport(o) {
27
+ const lines = o.numstat
28
+ .split('\n')
29
+ .map((l) => l.split('\t'))
30
+ .filter((p) => p.length >= 3)
31
+ .reduce((n, p) => n + (Number(p[0]) || 0), 0);
32
+ const kept = o.commits === 0 ? 'aucun commit' : `${o.commits} commit${o.commits > 1 ? 's' : ''} conservé${o.commits > 1 ? 's' : ''}`;
33
+ const head = `coupé au délai de ${o.minutes} min : ${kept}`;
34
+ if (o.files.length === 0)
35
+ return `${head}, rien de non commité`;
36
+ const tests = o.files.filter(isTest);
37
+ const names = o.files.length > LISTED ? `${o.files.slice(0, LISTED).join(', ')}, …` : o.files.join(', ');
38
+ const t = tests.length ? `, dont ${tests.length} de test (${tests.length > LISTED ? `${tests.slice(0, LISTED).join(', ')}, …` : tests.join(', ')})` : ', aucun de test';
39
+ return `${head} ; travail NON commité : ${o.files.length} fichier${o.files.length > 1 ? 's' : ''} (${names})${t}${lines ? `, ${lines} lignes ajoutées` : ''} — laissé dans le dépôt, à commiter ou à reprendre par le lead`;
40
+ }