@sylad/cadence 1.39.0 → 1.41.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -3
  4. package/agents/spec-writer.md +1 -0
  5. package/agents/ux-reviewer.md +2 -0
  6. package/dist/cli.js +49 -62
  7. package/dist/config-set.js +2 -0
  8. package/dist/config.js +8 -8
  9. package/dist/deliver-lots.js +37 -0
  10. package/dist/deliver.js +15 -39
  11. package/dist/docs-ok-file.js +25 -0
  12. package/dist/lot-lines.js +51 -0
  13. package/dist/memory-index.js +113 -0
  14. package/dist/orchestrate/command.js +8 -8
  15. package/dist/orchestrate/continue.js +4 -2
  16. package/dist/orchestrate/cycle.js +23 -29
  17. package/dist/orchestrate/deja-traitee.js +76 -0
  18. package/dist/orchestrate/integrate.js +7 -2
  19. package/dist/orchestrate/lot-budget.js +2 -2
  20. package/dist/orchestrate/maquette.js +3 -0
  21. package/dist/orchestrate/spec.js +51 -6
  22. package/dist/orchestrate/table.js +29 -11
  23. package/dist/plan-extras.js +118 -0
  24. package/dist/plan-read.js +144 -0
  25. package/dist/plan-states.js +119 -0
  26. package/dist/plan.js +91 -157
  27. package/dist/raf-close-checks.js +25 -0
  28. package/dist/raf-status.js +28 -0
  29. package/dist/session.js +8 -0
  30. package/dist/state.js +23 -0
  31. package/dist/verify.js +20 -3
  32. package/dist/yaml-keys.js +2 -0
  33. package/docs/concepts-livraison-dark.svg +32 -0
  34. package/docs/concepts-livraison-light.svg +32 -0
  35. package/docs/concepts-lot-dark.svg +52 -0
  36. package/docs/concepts-lot-light.svg +52 -0
  37. package/docs/concepts-vague-dark.svg +39 -0
  38. package/docs/concepts-vague-light.svg +39 -0
  39. package/docs/concepts-worktrees-dark.svg +45 -0
  40. package/docs/concepts-worktrees-light.svg +45 -0
  41. package/docs/concepts.md +159 -0
  42. package/docs/deliver.md +24 -2
  43. package/docs/fonctionnement.md +11 -5
  44. package/docs/lead.md +10 -6
  45. package/docs/orchestrate.md +22 -5
  46. package/docs/overview.md +1 -1
  47. package/docs/perf.md +1 -0
  48. package/docs/qualite.md +13 -3
  49. package/docs/raf.md +42 -5
  50. package/docs/session.md +32 -0
  51. package/docs/spec.md +5 -2
  52. package/docs/whats-new.md +6 -0
  53. package/package.json +10 -1
  54. package/skills/lead/SKILL.md +6 -0
  55. package/skills/session-close/SKILL.md +6 -0
  56. package/templates/orchestrate/spec.md +2 -2
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { RafError } from './plan.js';
5
+ import { claudeConfigDir, projectLogDir } from './orchestrate/launch.js';
6
+ /** Taille (octets) au-delà de laquelle l'index MEMORY.md est signalé quand `memory.indexMaxBytes` n'est pas posé (L442). */
7
+ const MEMORY_INDEX_MAX_BYTES = 12_000;
8
+ /** L'index de la mémoire de Claude Code du dossier `dir` (celui où la session est lancée, jamais la racine git). */
9
+ function memoryIndexPath(env, dir) {
10
+ return join(projectLogDir(claudeConfigDir(env), dir), 'memory', 'MEMORY.md');
11
+ }
12
+ // « - », puis au plus « ** », « [ » et « ⚠ », puis un jour « jj-mm » : le nom du fichier lié ne compte pas.
13
+ const DATED = /^- (?:\*\*)?\[?(?:⚠️?\s*)?(?:0[1-9]|[12]\d|3[01])-(?:0[1-9]|1[0-2])(?!\d)/;
14
+ /** Les entrées datées d'un index : leur nombre et leur poids en octets (fin de ligne comprise). */
15
+ function datedEntries(text) {
16
+ let count = 0;
17
+ let bytes = 0;
18
+ const lines = text.replace(/^\uFEFF/, '').split('\n');
19
+ for (const [i, line] of lines.entries()) {
20
+ const bare = line.replace(/\r$/, '');
21
+ if (!DATED.test(bare))
22
+ continue;
23
+ count++;
24
+ // la fin de ligne ne compte que si elle existe : la dernière ligne peut ne pas en avoir
25
+ bytes += Buffer.byteLength(line) + (i < lines.length - 1 ? 1 : 0);
26
+ }
27
+ return { count, bytes };
28
+ }
29
+ /** Mesure l'index : absent ou au plus égal au seuil = `ok` ; une lecture impossible se dit au lieu de lever. Ne modifie rien. */
30
+ function checkMemoryIndex(env, dir, max) {
31
+ let buf;
32
+ try {
33
+ buf = readFileSync(memoryIndexPath(env, dir));
34
+ }
35
+ catch (e) {
36
+ const code = e.code;
37
+ return code === 'ENOENT' ? { kind: 'ok' } : { kind: 'unreadable', cause: e.message.split('\n')[0] };
38
+ }
39
+ if (buf.length <= max)
40
+ return { kind: 'ok' };
41
+ return { kind: 'over', size: buf.length, max, ...datedEntries(buf.toString('utf8')) };
42
+ }
43
+ const NO_DATED = "aucune entrée datée à regrouper dans l'index MEMORY.md";
44
+ /** Les lignes de la section « Mémoire » de `session close`, précédée d'une ligne vide (aucune quand l'index est sain). */
45
+ function closeLines(c) {
46
+ if (c.kind === 'ok')
47
+ return [];
48
+ if (c.kind === 'unreadable')
49
+ return [`\nMémoire : l'index MEMORY.md est illisible (${c.cause}), sa taille n'est pas contrôlée`];
50
+ return [
51
+ `\nMémoire : l'index MEMORY.md pèse ${c.size} octets (seuil ${c.max})`,
52
+ c.count === 0
53
+ ? ` ${NO_DATED}`
54
+ : ` ${c.count} entrée(s) datée(s) (${c.bytes} octets) à regrouper sous une ligne vers un index secondaire — le skill propose, l'humain décide`,
55
+ ];
56
+ }
57
+ function tourLines(c) {
58
+ if (c.kind === 'ok')
59
+ return [];
60
+ if (c.kind === 'unreadable')
61
+ return [`mémoire · MEMORY.md illisible (${c.cause})`];
62
+ const tail = c.count === 0 ? 'aucune entrée datée à regrouper' : `${c.count} entrée(s) datée(s) à regrouper (cadence session close détaille)`;
63
+ return [`mémoire · MEMORY.md ${c.size} octets > seuil ${c.max} · ${tail}`];
64
+ }
65
+ /**
66
+ * Clé `memory.indexMaxBytes` de cadence.yaml : seuil, en octets, de l'avertissement ; `undefined` quand elle est absente.
67
+ * Une valeur qui n'est pas un entier ≥ 1 est refusée.
68
+ */
69
+ export function readMemoryIndexMax(file) {
70
+ if (!existsSync(file))
71
+ return undefined;
72
+ let raw;
73
+ try {
74
+ raw = parse(readFileSync(file, 'utf8'));
75
+ }
76
+ catch (e) {
77
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
78
+ }
79
+ const m = raw?.memory;
80
+ if (m == null)
81
+ return undefined;
82
+ if (typeof m !== 'object' || Array.isArray(m))
83
+ throw new RafError(`${file} : memory doit être un objet { indexMaxBytes }`);
84
+ for (const k of Object.keys(m))
85
+ if (k !== 'indexMaxBytes')
86
+ throw new RafError(`${file} : memory.${k} inconnu (attendu : indexMaxBytes)`);
87
+ const v = m.indexMaxBytes;
88
+ if (v === undefined)
89
+ return undefined;
90
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1)
91
+ throw new RafError(`${file} : memory.indexMaxBytes doit être un entier ≥ 1 (octets), pas ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
92
+ return v;
93
+ }
94
+ /**
95
+ * Pour `session close` : le contexte de la mesure, relu à l'appel (une clé invalide lève alors avant tout rapport). Le seuil vient
96
+ * du `cadence.yaml` que la commande lit.
97
+ */
98
+ export function closeMemoryLines(env, dir, configFile) {
99
+ return () => closeLines(checkMemoryIndex(env, dir, readMemoryIndexMax(configFile) ?? MEMORY_INDEX_MAX_BYTES));
100
+ }
101
+ /**
102
+ * Pour `lead tour` : la ligne « mémoire » du dossier parent, avec le seuil de son cadence.yaml. Une clé invalide n'arrête pas le tour : une ligne d'erreur
103
+ * remplace la ligne « mémoire » ; un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour et n'en ajoute pas ici.
104
+ */
105
+ export function tourMemoryLines(env, parent) {
106
+ try {
107
+ return tourLines(checkMemoryIndex(env, parent, readMemoryIndexMax(join(parent, 'cadence.yaml')) ?? MEMORY_INDEX_MAX_BYTES));
108
+ }
109
+ catch (e) {
110
+ const message = e.message;
111
+ return / : memory/.test(message) ? [`cadence.yaml · ✗ erreur : ${message.replace(/^.*cadence\.yaml : /, '')}`] : [];
112
+ }
113
+ }
@@ -24,7 +24,7 @@ import { acceptationFile, acceptText, redAcceptations } from './accept.js';
24
24
  import { noTestGate, noTestText } from './no-test.js';
25
25
  import { ficheReviewText } from './fiche.js';
26
26
  import { maquetteText } from './maquette.js';
27
- import { costlyHypotheses, specScreen, specText } from './spec.js';
27
+ import { budgetStage, costlyHypotheses, lightLot, specText, stageScreen } from './spec.js';
28
28
  import { fichesDuDossier } from '../spec.js';
29
29
  import { candidates, fillTargets, handedBackStreak, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
30
30
  import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
@@ -320,7 +320,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
320
320
  }
321
321
  const small = lot.estimate <= 0.5 || lot.quickwin;
322
322
  const goal = args.goal ?? lot.goal;
323
- const screen = specScreen(t.repo, env.config.spec.dir, lot);
323
+ const screen = stageScreen(t.repo, env.config.spec.dir, lot, env.config.maquette);
324
324
  const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, ...(goal ? { goal } : {}), visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
325
325
  if (neighbours.repos.length)
326
326
  state.repos = neighbours.repos;
@@ -329,7 +329,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
329
329
  state.express = true;
330
330
  state.light = lot.estimate <= env.config.review.threshold;
331
331
  const escalating = !!env.config.escalation;
332
- state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined, escalating);
332
+ state.budget = lotBudget(lot.estimate, budgetStage(lot, screen, env.config.maquette, t.repo, env.config.spec.dir), escalating);
333
333
  if (escalating)
334
334
  state.escalationReserve = ESCALATION_RESERVE;
335
335
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
@@ -442,7 +442,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
442
442
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
443
443
  if (l.goal)
444
444
  io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
445
- io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined, l.escalationReserve ? env.config.escalation?.model : undefined)})`);
445
+ io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, budgetStage(lot, l.screen, env.config.maquette, l.repo, env.config.spec.dir), l.escalationReserve ? env.config.escalation?.model : undefined)})`);
446
446
  const mine = lots.filter((o) => o.repo === l.repo);
447
447
  const together = env.config.worktrees && (mine.length > 1 || multiSlot) && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
448
448
  io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
@@ -459,7 +459,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
459
459
  }
460
460
  const steps = [{ kind: 'implement', model: l.model }];
461
461
  const specStage = !!(l.screen || l.specLot);
462
- if (specStage && env.config.accept)
462
+ if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir))
463
463
  steps.unshift({ kind: 'accept', model: 'sonnet' });
464
464
  if (l.screen && env.config.maquette)
465
465
  steps.unshift({ kind: 'maquette', model: env.config.review.full });
@@ -513,12 +513,12 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
513
513
  const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '', diff: 'git diff <sha de départ>..HEAD' };
514
514
  vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
515
515
  if (specStage)
516
- vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
516
+ vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir), back: !!l.specLot });
517
517
  if (l.screen && env.config.maquette)
518
- vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews });
518
+ vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir) });
519
519
  if (l.specLot && env.config.fiche)
520
520
  vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
521
- if (specStage && env.config.accept) {
521
+ if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir)) {
522
522
  const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
523
523
  vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
524
524
  }
@@ -9,7 +9,7 @@ import { RafError } from '../plan.js';
9
9
  import { lotBudget, projectEnv } from './command.js';
10
10
  import { readyFree } from '../ready-free.js';
11
11
  import { lotFinished, lotKey } from './state.js';
12
- import { specScreen } from './spec.js';
12
+ import { budgetStage, stageScreen } from './spec.js';
13
13
  import { STOP_REQUESTED } from './cycle.js';
14
14
  /** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
15
15
  export function parseUntil(text, now) {
@@ -90,11 +90,13 @@ export function candidates(launch, o) {
90
90
  }
91
91
  let specDir = 'docs/specs';
92
92
  let escalating = false;
93
+ let maquette = true;
93
94
  let setting;
94
95
  try {
95
96
  const config = projectEnv(dir).config;
96
97
  specDir = config.spec.dir;
97
98
  escalating = !!config.escalation;
99
+ maquette = config.maquette;
98
100
  setting = config.continue;
99
101
  }
100
102
  catch {
@@ -108,7 +110,7 @@ export function candidates(launch, o) {
108
110
  o.skipped?.push(`${project}:${lot.id} écarté du tirage : ${why}`);
109
111
  continue;
110
112
  }
111
- if (lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined, escalating) > o.remaining)
113
+ if (lotBudget(lot.estimate, budgetStage(lot, stageScreen(dir, specDir, lot, maquette), maquette, dir, specDir), escalating) > o.remaining)
112
114
  continue;
113
115
  out.push({ project, dir, lot });
114
116
  }
@@ -36,7 +36,7 @@ import { lotIds, referenceIdsText } from '../ref/coverage.js';
36
36
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
37
37
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
38
38
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
39
- import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
39
+ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, lightLot, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
40
40
  import { handBackWithoutFinding } from './no-finding.js';
41
41
  import { decisionsText, humanDecisions } from './decisions.js';
42
42
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, expressSummary, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
@@ -439,24 +439,19 @@ export function briefName(l, kind, integration = false) {
439
439
  return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
440
440
  return kind;
441
441
  }
442
- /** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
443
- function ficheTemplate() {
442
+ /** Fichier livré avec le paquet à côté des gabarits, joint à un brief (la session ne lit pas le dossier du paquet) ; undefined s'il manque. */
443
+ function packageFile(name) {
444
444
  try {
445
- return readFileSync(join(TEMPLATES_DIR, '..', 'fiche.yaml'), 'utf8');
445
+ return readFileSync(join(TEMPLATES_DIR, '..', name), 'utf8');
446
446
  }
447
447
  catch {
448
448
  return undefined;
449
449
  }
450
450
  }
451
+ /** Gabarit de fiche livré avec le paquet, joint au brief de la spec. */
452
+ const ficheTemplate = () => packageFile('fiche.yaml');
451
453
  /** Gabarit de la partie back de la fiche (L187), joint au brief de la spec d'un lot `spec: true`. */
452
- function ficheBackTemplate() {
453
- try {
454
- return readFileSync(join(TEMPLATES_DIR, '..', 'fiche-back.yaml'), 'utf8');
455
- }
456
- catch {
457
- return undefined;
458
- }
459
- }
454
+ const ficheBackTemplate = () => packageFile('fiche-back.yaml');
460
455
  /** Le lot passe par l'étape spec : il déclare `screen:` (écran) ou `spec: true` (comportement sans écran, L188). */
461
456
  const hasSpecStage = (l) => !!(l.screen || l.specLot);
462
457
  /**
@@ -465,14 +460,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
465
460
  */
466
461
  const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
467
462
  /** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
468
- function acceptRunner() {
469
- try {
470
- return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
471
- }
472
- catch {
473
- return undefined;
474
- }
475
- }
463
+ const acceptRunner = () => packageFile('acceptation-runner.mjs');
476
464
  /** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
477
465
  function ficheOf(c) {
478
466
  const l = c.lot;
@@ -543,7 +531,7 @@ function briefFor(c, kind, local = false) {
543
531
  vars.spec = specText({
544
532
  dir: c.config.spec.dir,
545
533
  render: c.config.spec.render,
546
- screen: l.screen,
534
+ screen: l.screen, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir),
547
535
  back,
548
536
  problems: l.specProblems,
549
537
  review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen, session) : reviewedText(reviewed, session),
@@ -562,7 +550,7 @@ function briefFor(c, kind, local = false) {
562
550
  vars.reponse = '';
563
551
  }
564
552
  if (kind === 'maquette') {
565
- vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats });
553
+ vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir) });
566
554
  vars.reponse = '';
567
555
  }
568
556
  if (kind === 'accept') {
@@ -1300,18 +1288,21 @@ function summarize(rep, head, repoHeads) {
1300
1288
  const majeurs = Math.max(rep.majeurs, count('majeur'));
1301
1289
  return { conforme: bloquants === 0 && majeurs === 0, bloquants, majeurs, mineurs: Math.max(rep.mineurs, count('mineur')), verdict: rep.verdict.trim(), sousTaches: rep.sousTaches, nonVerifie: rep.nonVerifie, head, ...(repoHeads ? { repoHeads } : {}) };
1302
1290
  }
1303
- function propose(c, text) {
1291
+ function propose(c, text, head) {
1304
1292
  if (!c.lot.proposals.includes(text))
1305
1293
  c.lot.proposals.push(text);
1294
+ if (head)
1295
+ (c.lot.proposalHeads ??= {})[text] ??= head;
1306
1296
  }
1307
1297
  const minorLine = (source, k) => `[mineur ${source}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`;
1308
1298
  const withoutLine = (line) => line.replace(/^(\[mineur \w+\] .*?):\d+ — /, '$1 — ');
1309
- function addProposals(c, rep, source, withMinors = true) {
1299
+ function addProposals(c, rep, source, head, withMinors = true) {
1310
1300
  if (withMinors)
1311
1301
  for (const k of rep.constats.filter((k) => k.gravite === 'mineur'))
1312
1302
  propose(c, minorLine(source, k));
1313
- for (const t of rep.sousTaches)
1314
- propose(c, `[sous-tâche ${source}] ${t}`);
1303
+ // La première revue qui émet la proposition en garde la date : une revue suivante qui la redit ne la rajeunit pas.
1304
+ for (const t of rep.sousTaches.map((t) => `[sous-tâche ${source}] ${t}`))
1305
+ propose(c, t, head);
1315
1306
  }
1316
1307
  function minorConstats(rep) {
1317
1308
  return rep.constats.filter((k) => k.gravite === 'mineur').map((k) => ({ source: 'code', gravite: k.gravite, fichier: k.fichier, ligne: k.ligne, texte: k.texte }));
@@ -1452,6 +1443,9 @@ function afterMaquette(c, fiche) {
1452
1443
  // Des constats de la revue de fiche attendent la revue de la maquette (lot mixte) : les deux partent ensemble à la seconde passe `spec`.
1453
1444
  if (l.ficheConstats?.length)
1454
1445
  return 'spec';
1446
+ // Une maquette légère n'a pas de règles de comportement à verrouiller : pas d'accept, même si orchestrate.accept est déclaré.
1447
+ if (lightLot(l, c.loadPlan().lot(l.lot), c.config.maquette, wd(c), c.config.spec.dir))
1448
+ return 'implement';
1455
1449
  if (!c.config.accept) {
1456
1450
  if (!l.warnings.includes(NO_ACCEPT))
1457
1451
  l.warnings.push(NO_ACCEPT);
@@ -1782,7 +1776,7 @@ async function reviewSpecStep(c, r) {
1782
1776
  const summary = summarize(rep, done.after.head ?? '');
1783
1777
  const passes = (l[r.kind]?.passes ?? 0) + 1;
1784
1778
  // Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
1785
- addProposals(c, rep, r.kind, r.kind !== 'fiche');
1779
+ addProposals(c, rep, r.kind, summary.head, r.kind !== 'fiche');
1786
1780
  // Un lot rendu sur le refus d'écrire ses notes (R26) rejoue cette revue à la reprise : le compte des passes n'est posé qu'une fois les notes écrites.
1787
1781
  if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
1788
1782
  return;
@@ -2515,7 +2509,7 @@ async function reviewStep(c, kind) {
2515
2509
  const summary = summarize(rep, done.after.head ?? '', done.others.length ? Object.fromEntries(done.others.map((o) => [o.repo.rel, o.after.head ?? ''])) : undefined);
2516
2510
  if (kind === 'ux') {
2517
2511
  l.ux = summary;
2518
- addProposals(c, rep, 'ux');
2512
+ addProposals(c, rep, 'ux', summary.head);
2519
2513
  l.uxVerdict = summary.verdict;
2520
2514
  l.constats = blockingConstats(rep, 'ux');
2521
2515
  save(c);
@@ -2569,7 +2563,7 @@ async function reviewStep(c, kind) {
2569
2563
  l.proposals = left;
2570
2564
  l.minorLines = [];
2571
2565
  }
2572
- addProposals(c, rep, 'code', !minorPass);
2566
+ addProposals(c, rep, 'code', summary.head, !minorPass);
2573
2567
  if (summary.conforme)
2574
2568
  (l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
2575
2569
  if (minorPass) {
@@ -0,0 +1,76 @@
1
+ import { changedFiles, isAncestor, resolveCommit } from '../git.js';
2
+ /** Début d'une proposition de sous-tâche d'une revue (`addProposals`) : les seules marquées, un mineur étant déjà une ligne de fichier précise. */
3
+ const SUBTASK = '[sous-tâche ';
4
+ /** Les fichiers que le texte d'une proposition cite (`src/a.ts`, `src/a.ts:42`, `` `docs/z.md` ``), dans l'ordre, sans doublon ; les adresses web et les nombres (`1.5`) n'en sont pas. */
5
+ export function citedFiles(text) {
6
+ const out = [];
7
+ for (const raw of text.split(/[\s`'"“”«»(),;*[\]<>]+/)) {
8
+ const tok = raw.replace(/(:\d+(-\d+)?)+$/, '').replace(/[.:!?]+$/, '').replace(/^(\.\/)+/, '');
9
+ if (!tok || tok.includes('://') || !/^[\p{L}\p{N}_@.\-/]+\.[A-Za-z][A-Za-z0-9]+$/u.test(tok) || out.includes(tok))
10
+ continue;
11
+ out.push(tok);
12
+ }
13
+ return out;
14
+ }
15
+ /**
16
+ * La lecture de git : parmi `commits` (sha du lot, du plus ancien au plus récent), ceux qui ne sont pas dans l'histoire de la revue
17
+ * `head`, puis le premier qui modifie `file` (chemin entier ou fin de chemin). Un `head` que le dépôt ne connaît pas, ou une
18
+ * lecture impossible, ne marque rien : mieux vaut une proposition sans marque qu'une marque fausse.
19
+ */
20
+ export function touchedByLotCommits(commits) {
21
+ // Une lecture par lot et par commit pour tout le rendu : le tableau pose la question pour chaque couple (proposition, fichier cité).
22
+ const lotShas = new Map();
23
+ const files = new Map();
24
+ const filesOf = (repo, c) => {
25
+ const key = `${repo}\0${c}`;
26
+ if (!files.has(key)) {
27
+ try {
28
+ files.set(key, changedFiles(repo, c));
29
+ }
30
+ catch {
31
+ files.set(key, null);
32
+ }
33
+ }
34
+ return files.get(key);
35
+ };
36
+ return (l, head, file) => {
37
+ if (!resolveCommit(l.repo, head))
38
+ return null;
39
+ if (!lotShas.has(l))
40
+ lotShas.set(l, commits(l));
41
+ for (const c of lotShas.get(l)) {
42
+ if (isAncestor(l.repo, c, head))
43
+ continue;
44
+ const changed = filesOf(l.repo, c);
45
+ if (!changed)
46
+ return null;
47
+ if (changed.some((f) => f === file || f.endsWith(`/${file}`)))
48
+ return c.slice(0, 7);
49
+ }
50
+ return null;
51
+ };
52
+ }
53
+ /**
54
+ * La ligne de proposition rendue au lead (L422) : une sous-tâche proposée dont un fichier cité a été modifié par un commit du lot
55
+ * postérieur à la revue qui l'a émise (`l.proposalHeads`) porte « — peut-être déjà traitée (<sha>) » ; les autres lignes sont inchangées.
56
+ * 28-10 : L230 ouvert sur des propositions déjà corrigées par la passe de correction, 5 passes perdues.
57
+ */
58
+ export function proposalLine(l, proposal, touched) {
59
+ const head = l.proposalHeads?.[proposal];
60
+ if (!proposal.startsWith(SUBTASK) || !head)
61
+ return proposal;
62
+ for (const file of citedFiles(proposal)) {
63
+ let sha = null;
64
+ try {
65
+ sha = touched(l, head, file);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (sha)
71
+ return `${proposal} — peut-être déjà traitée (${sha})`;
72
+ }
73
+ return proposal;
74
+ }
75
+ /** La lecture à employer : celle que l'appelant donne (tests), sinon git sur les commits du lot (`lotCommits` de l'appelant, sinon `fromGit` : ceux des étapes). */
76
+ export const touchedReader = (deps, fromGit) => deps.touchedSince ?? touchedByLotCommits(deps.lotCommits ?? fromGit);
@@ -6,6 +6,7 @@ import { sharedStateDir } from '../state.js';
6
6
  import { releaseLock, takeLock } from './lock.js';
7
7
  import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
8
8
  import { mutexFor } from './mutex.js';
9
+ import { readPlanConfig } from '../config.js';
9
10
  import { Plan } from '../plan.js';
10
11
  import { parseDocument } from 'yaml';
11
12
  import { git, gitFailure, tryGit } from './worktree.js';
@@ -91,11 +92,15 @@ function resolvePlan(work) {
91
92
  return false;
92
93
  const file = files[0];
93
94
  try {
95
+ // Les champs d'extension déclarés (L464) sont des propriétés des lots : sans les réglages, `bare` ne verrait pas un champ changé.
96
+ // Seuls les champs d'extension sont passés : le `plan.onWrite` du projet ne doit pas se lancer pendant un rebase.
97
+ const extras = readPlanConfig(join(work, 'cadence.yaml'), work)?.settings.extras;
98
+ const settings = extras ? { extras } : {};
94
99
  const stage = (n) => {
95
100
  const text = run(work, 'show', `:${n}:${file}`);
96
101
  writeFileSync(join(work, file), text);
97
102
  const head = parseDocument(text).toJSON();
98
- return { lots: Plan.load(join(work, file)).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
103
+ return { lots: Plan.load(join(work, file), settings).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
99
104
  };
100
105
  const [b, o, t] = [stage(1), stage(2), stage(3)];
101
106
  const [base, ours, theirs] = [b.lots, o.lots, t.lots];
@@ -109,7 +114,7 @@ function resolvePlan(work) {
109
114
  if (added.some((t) => review(ours.find((o) => o.id === t.id)) !== review(base.find((b) => b.id === t.id))))
110
115
  return false;
111
116
  writeFileSync(join(work, file), run(work, 'show', `:2:${file}`));
112
- const plan = Plan.load(join(work, file));
117
+ const plan = Plan.load(join(work, file), settings);
113
118
  for (const t of added)
114
119
  plan.recordReview(t.id, t.review.verdict, t.review.date, t.review.commit ?? null, t.review.repos, t.review.reporte);
115
120
  plan.save();
@@ -38,7 +38,7 @@ function ordinaryBasis(estimate, stage) {
38
38
  const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
39
39
  if (!stage)
40
40
  return basis;
41
- return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
41
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour ${stage === 'light' ? 'les étapes spec et maquette' : `les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`}`;
42
42
  }
43
43
  /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
44
44
  export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
@@ -46,5 +46,5 @@ const lotBaseBudget = (estimate, stage) => {
46
46
  const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
47
47
  if (stage === 'screen')
48
48
  return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
49
- return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
49
+ return ordinary + (stage === 'spec' || stage === 'light' ? SPEC_STAGE_ALLOCATION : 0);
50
50
  };
@@ -15,6 +15,9 @@ export function maquetteText(o) {
15
15
  ? `This is review ${rank} of ${o.max}${rank >= o.max ? ', the last review' : ''}: the spec writer fixed the points of the previous one (below). Check them first, then run the whole sweep again and report ALL the blocking and major findings of it in this report. Mark \`inchange: true\` on every blocking or major finding about a point the spec writer did not change and the previous review had not raised.`
16
16
  : `This is the first review of this mockup (${o.max} review${o.max > 1 ? 's' : ''} at most): there is no later review to keep a finding for. Report ALL the blocking and major findings in this first report; do not keep one back for a later review.`,
17
17
  ];
18
+ if (o.light) {
19
+ lines.push(`Light mockup (L459): the sheet covers only the states the lot touches. Sweep those states only, at ${LARGEURS.join(' and ')} px, and compare the target of each one to the capture of the current screen \`${folder}/actuel-<état>-<largeur>.png\` (a free file, not checked by the program; when one is missing the spec writer said so under "nonVerifie": judge the target against the code and the neighbouring pages). Report what makes the target worse than the current screen or inconsistent with the rest of the application, not the defects of the screen the lot does not touch.`);
20
+ }
18
21
  if (later && o.previous?.length)
19
22
  lines.push('', 'Points of the previous review that the spec writer had to fix:', ...bulletLines(o.previous));
20
23
  return lines.join('\n');
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
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
+ import { EXPRESS_MAX_ESTIMATE } from './express.js';
7
8
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
9
  const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
9
10
  /** Chemin (relatif au dépôt) de la fiche du lot sous `<dir>/<écran ou sujet>/fiche.yaml` (`ecran.lot` ou `sujet.lot`), ou null : sert à ne pas rejouer une spec déjà écrite. */
@@ -24,6 +25,34 @@ export function specScreen(repo, dir, lot) {
24
25
  const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
25
26
  return entered ? lot.screen : undefined;
26
27
  }
28
+ /**
29
+ * Maquette légère (L459) : un lot visible, sans `spec: true`, d'estimate au-dessus de la retouche (voie express), dont le `screen:` du plan n'est pas `page`
30
+ * (`component`, `journey` ou absent) passe par spec → maquette avant le code, sans accept. Se déduit du plan à chaque appel, sans champ d'état :
31
+ * `orchestrate.maquette: false` la retire (la maquette légère est la revue de maquette, sans elle rien n'est à valider).
32
+ */
33
+ export function lightMaquette(lot, maquette) {
34
+ return maquette && !!lot.visible && !lot.spec && typeof lot.estimate === 'number' && lot.estimate > EXPRESS_MAX_ESTIMATE && lot.screen !== 'page';
35
+ }
36
+ /**
37
+ * Le lot a une acceptation scellée (`acceptation-<lot>.json` à côté d'une fiche à bloc `ecran` de `dir`) : il est entré dans la chaîne complète
38
+ * avant L459 (spec → maquette → accept) et la garde, même s'il répond aujourd'hui à `lightMaquette` ; une maquette légère n'écrit jamais d'acceptation.
39
+ */
40
+ export function sealedAcceptation(repo, dir, lot) {
41
+ const acceptation = acceptationFile('fiche.yaml', lot);
42
+ return fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && existsSync(join(repo, dir, name, acceptation)));
43
+ }
44
+ /** Le lot est une maquette légère : son état a une étape spec (`screen` posé au lancement), son lot du plan répond à `lightMaquette` et il n'a pas d'acceptation scellée (`sealedAcceptation`). Relu du plan et du dépôt à chaque appel, aucun champ d'état. */
45
+ export const lightLot = (state, lot, maquette, repo, dir) => !!state.screen && lightMaquette(lot, maquette) && !sealedAcceptation(repo, dir, lot.id);
46
+ /** `screen` de l'état d'un lot à étape spec : celui de `specScreen`, ou, pour une maquette légère, celui du plan (`component` quand le plan n'en déclare pas : l'état ne sert alors qu'à dire « ce lot a une étape spec »). */
47
+ export function stageScreen(repo, dir, lot, maquette) {
48
+ return specScreen(repo, dir, lot) ?? (lightMaquette(lot, maquette) ? (lot.screen ?? 'component') : undefined);
49
+ }
50
+ /** Catégorie de budget d'un lot (`lotBudget`) : `screen` pour un écran page, `light` pour une maquette légère, `spec` pour un lot `spec: true` sans écran. */
51
+ export function budgetStage(lot, stateScreen, maquette, repo, dir) {
52
+ if (stateScreen)
53
+ return lightLot({ screen: stateScreen }, lot, maquette, repo, dir) ? 'light' : 'screen';
54
+ return lot.spec ? 'spec' : undefined;
55
+ }
27
56
  /** Texte sans accents ni majuscules, pour les mots-clés. */
28
57
  const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
29
58
  /**
@@ -267,20 +296,36 @@ export function hypothesisLines(doc) {
267
296
  }
268
297
  /** Texte de la note du lot pour une hypothèse (une note par règle). */
269
298
  export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
299
+ /** Consigne de rendu de la maquette : la commande que le projet déclare (`orchestrate.spec.render`), sinon Playwright. `use` : comment la reprendre. */
300
+ const renderLine = (render, use) => render
301
+ ? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${render}\` (from the repository root). ${use}; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
302
+ : 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).';
303
+ /** Consigne de la maquette d'un lot `screen: page` (ou déjà entré dans la chaîne complète). */
304
+ const pageLines = (o) => [
305
+ `The lot declares \`screen: ${o.screen}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
306
+ `Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`,
307
+ renderLine(o.render, 'Use it'),
308
+ ];
309
+ /** Consigne de la maquette légère (L459) : l'écran actuel et la version visée des seuls états touchés. */
310
+ const lightLines = (o) => [
311
+ `This lot is a visible lot that is not a whole page: write a light mockup, before any code. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
312
+ 'Cover only the states the lot touches (not the whole screen). For each touched state, capture the CURRENT screen of the application at each width, then the target version: the sheet and the mockup are limited to the states, fields and rules the lot changes.',
313
+ `Name the captures of the current screen \`actuel-<état>-<largeur>.png\` and those of the target \`maquette-<état>-<largeur>.png\`, one per touched state and per width (${LARGEURS.join(' and ')} px; e.g. \`actuel-vide-1440.png\`, \`maquette-vide-1440.png\`), in the folder of the sheet. The program checks the captures of the target, not the \`actuel-*\` ones.`,
314
+ 'If the current screen cannot be captured (the application does not start, the page is protected), say so under "nonVerifie" and base the mockup on the code: never invent a capture of the current screen.',
315
+ 'Each touched state is a section `id="etat-<état>"` of `maquette.html`, and each touched field carries a `data-testid`.',
316
+ renderLine(o.render, 'Use it for the target'),
317
+ ];
270
318
  /** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
271
319
  export function specText(o) {
272
320
  const lines = [];
273
- if (o.screen) {
274
- lines.push(`The lot declares \`screen: ${o.screen}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`, `Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`, o.render
275
- ? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${o.render}\` (from the repository root). Use it; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
276
- : 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).');
277
- }
321
+ if (o.screen)
322
+ lines.push(...(o.light ? lightLines(o) : pageLines(o)));
278
323
  if (o.back) {
279
324
  lines.push(o.screen
280
325
  ? `The sheet has both parts, \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\`, in the same folder (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the API calls of the screen are listed once in \`contrats\`, and the actions point to them with \`contrat: Ax\`.`
281
326
  : `The lot declares \`spec: true\` (it changes a behaviour, with no screen). The specs of this project live in \`${o.dir}/<sujet>/\` (one folder per subject, \`<sujet>\` = the kebab-case \`sujet.id\` of the sheet); the sheet has no screen part.`);
282
327
  }
283
- if (o.screen && !o.back) {
328
+ if (o.screen && !o.back && !o.light) {
284
329
  lines.push(`If the lot also changes a behaviour (an API call, a stored datum, a contract), add the back part to the same sheet: \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\` (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the program then also has the sheet reviewed (rules sourced, contradictions, errors and limits, one test per rule), before the mockup. A lot that only draws a screen keeps a sheet without \`sujet\`.`);
285
330
  }
286
331
  if (o.template)