@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
package/dist/perf.js CHANGED
@@ -4,14 +4,18 @@ import { CAUSES, isReview as isReviewKind } from './corrections.js';
4
4
  import { addDays, diffDays, isDay, toDay } from './dates.js';
5
5
  import { gitCommonDir } from './git.js';
6
6
  import { findProjects } from './lead.js';
7
- import { readDeliveries, readFailedDeliveries } from './state.js';
7
+ import { FAIL_CLASSES, readDeliveries, readFailedEntries } from './state.js';
8
8
  /** Au-delà de cette dégradation relative, un indicateur porte un ⚠ (R12). */
9
9
  const DEGRADATION = 0.25;
10
10
  /** Sous ce nombre de lots dans l'une des deux fenêtres, pas de tendance (R13). */
11
11
  const MIN_SAMPLE = 5;
12
+ /** Durées de mur : sous ce nombre d'étapes dans l'une des deux fenêtres, pas de tendance (L280). Mesuré sur 12–18-10 contre 05–11 et 17–23-10 contre 10–16 : sous 10 étapes, la variation d'une fenêtre à l'autre n'est pas interprétable (`accept` −61 % sur 7, `ux` +31 % sur 5, sans changement du travail). */
13
+ const MIN_STEPS = 10;
12
14
  /** Goulot net (R19) : une cause pèse au moins cette part des passes classées, avec au moins MIN_CLASSIFIED passes classées. */
13
15
  const BOTTLENECK_SHARE = 0.5;
14
16
  const MIN_CLASSIFIED = 10;
17
+ /** Les étapes de la chaîne spec : un lot qui en a une est mesuré à part pour ses tokens (L240). */
18
+ const SPEC_CHAIN = new Set(['spec', 'fiche', 'maquette', 'accept']);
15
19
  /**
16
20
  * Les deux fenêtres de 7 jours calendaires locaux : la courante finit à `today` inclus, la précédente la précède.
17
21
  * `since` (jour ISO ou instant) ignore tout ce qui le précède : la fenêtre qui commence avant lui est tronquée à lui.
@@ -89,6 +93,7 @@ const DECISIONS = {
89
93
  atLeast2: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
90
94
  firstTime: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
91
95
  handedOut: 'la vague s\'arrête trop tôt ou le lead la rattrape à la main : revoir la fin de cycle',
96
+ precheck: 'le précheck livre peu de « oui » : le restreindre ou le confier au modèle local',
92
97
  ux: 'la revue d\'ergonomie coûte plus d\'un tour : renforcer la maquette',
93
98
  tokens: 'l\'estimate est mal calibrée : redécouper le lot ou recaler l\'estimate et les budgets',
94
99
  reviewShare: 'la revue pèse trop face à l\'écriture : alléger (seuil du lot léger, orchestrate.review.threshold)',
@@ -99,7 +104,7 @@ const DECISIONS = {
99
104
  qaBlocking: 'la revue laisse passer des défauts graves : durcir la revue avant de livrer davantage',
100
105
  duration: 'cette étape est lente : revoir ses timeouts, son effort ou son modèle',
101
106
  };
102
- function indicator(current, previous, lots, windows, dir, twin, nullNote = null) {
107
+ function indicator(current, previous, lots, windows, dir, twin, nullNote = null, minSample = MIN_SAMPLE) {
103
108
  const change = current !== null && previous !== null && previous !== 0 ? (current - previous) / previous : null;
104
109
  const worse = current !== null && previous !== null && dir !== null && (dir === 'down' ? current < previous : current > previous);
105
110
  const truncated = windows.current.truncated || windows.previous.truncated;
@@ -109,7 +114,7 @@ function indicator(current, previous, lots, windows, dir, twin, nullNote = null)
109
114
  const degraded = worse && (previous === 0 || Math.abs(change) > DEGRADATION + 1e-9);
110
115
  if (truncated)
111
116
  note = 'fenêtre tronquée';
112
- else if (degraded && (lots.current < MIN_SAMPLE || lots.previous < MIN_SAMPLE))
117
+ else if (degraded && (lots.current < minSample || lots.previous < minSample))
113
118
  note = 'pas de tendance : échantillon';
114
119
  else if (degraded && previous === 0)
115
120
  note = 'pas de tendance : précédente nulle';
@@ -118,12 +123,15 @@ function indicator(current, previous, lots, windows, dir, twin, nullNote = null)
118
123
  }
119
124
  return { current, previous, change, warn, twin, lots, note };
120
125
  }
126
+ /** Ordre des classes de tokens : estimate croissant, la classe « +spec » après la classe ordinaire du même estimate. */
127
+ const byEstimateKey = (a, b) => parseFloat(a) - parseFloat(b) || a.length - b.length;
121
128
  /** Les deux valeurs d'un indicateur, calculées fenêtre par fenêtre. */
122
129
  const both = (windows, f) => ({ current: f(windows.current), previous: f(windows.previous) });
123
130
  function causeWindow(passes, w, cache) {
124
131
  const counts = new Map();
125
132
  let unclassified = 0;
126
133
  let withoutFindings = 0;
134
+ const byReview = {};
127
135
  for (const { pass, day } of passes) {
128
136
  if (!inWindow(day, w))
129
137
  continue;
@@ -131,6 +139,8 @@ function causeWindow(passes, w, cache) {
131
139
  withoutFindings++;
132
140
  continue;
133
141
  }
142
+ if (pass.reviewKind)
143
+ byReview[pass.reviewKind] = (byReview[pass.reviewKind] ?? 0) + 1;
134
144
  const c = cache.get(pass.key);
135
145
  if (!c) {
136
146
  unclassified++;
@@ -145,7 +155,20 @@ function causeWindow(passes, w, cache) {
145
155
  .sort((a, b) => b.passes - a.passes)
146
156
  .slice(0, 3);
147
157
  const withFindings = classified + unclassified;
148
- return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings };
158
+ return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings, byReview };
159
+ }
160
+ /** La sorte d'étape sous laquelle la hausse se compte : `review` et `review-small` ne font qu'une (la taille du lot choisit l'une ou l'autre, `orchestrate.review.threshold` : le passage de l'une à l'autre n'est pas une hausse). */
161
+ const carrierKind = (kind) => (kind === 'review-small' ? 'review' : kind);
162
+ /** L'étape qui porte la hausse (L312) : la sorte d'étape dont la moyenne des jetons par lot monte le plus ; null sans hausse nette du total par lot, ou sans lot dans l'une des fenêtres. */
163
+ function carrierOf(current, previous) {
164
+ if (current.length === 0 || previous.length === 0)
165
+ return null;
166
+ const mean = (lots, kind) => lots.reduce((sum, l) => sum + (l.get(kind) ?? 0), 0) / lots.length;
167
+ const kinds = new Set([...current, ...previous].flatMap((l) => [...l.keys()]));
168
+ const rises = [...kinds].map((kind) => ({ kind, delta: mean(current, kind) - mean(previous, kind) }));
169
+ const net = rises.reduce((sum, r) => sum + r.delta, 0);
170
+ const top = rises.sort((a, b) => b.delta - a.delta || a.kind.localeCompare(b.kind))[0];
171
+ return net > 0 && top && top.delta > 0 ? top : null;
149
172
  }
150
173
  export function computePerf(input) {
151
174
  const { plans, cache, windows, today } = input;
@@ -218,13 +241,24 @@ export function computePerf(input) {
218
241
  return !hasPlans || n === 0 ? null : handedOutOf(w) / n;
219
242
  });
220
243
  const visibleRendered = (w) => rendered(w).filter((d) => d.j.visible);
244
+ // Une session qui voit l'application : l'étape ux, et la review-small d'un petit lot visible (sa seule revue d'ergonomie, `seesApp` du cycle) ; celle d'un lot plus gros n'est qu'une revue de code (L283).
245
+ const seesApp = (j, s) => s.status === 'ok' && (s.kind === 'ux' || (s.kind === 'review-small' && j.small));
221
246
  const ux = both(windows, (w) => {
222
247
  const set = visibleRendered(w);
223
- return set.length === 0 ? null : set.reduce((sum, d) => sum + d.j.steps.filter((s) => s.kind === 'ux' && s.status === 'ok').length, 0) / set.length;
248
+ return set.length === 0 ? null : set.reduce((sum, d) => sum + d.j.steps.filter((s) => seesApp(d.j, s)).length, 0) / set.length;
249
+ });
250
+ const dedicatedUx = both(windows, (w) => {
251
+ const set = visibleRendered(w);
252
+ return set.length === 0 ? null : set.filter((d) => d.j.steps.some((s) => s.kind === 'ux' && s.status === 'ok')).length / set.length;
253
+ });
254
+ const prechecked = (w) => rendered(w).filter((d) => d.j.precheck !== undefined);
255
+ const precheckYes = both(windows, (w) => {
256
+ const set = prechecked(w);
257
+ return set.length === 0 ? null : set.filter((d) => d.j.precheck === 'oui').length / set.length;
224
258
  });
225
259
  const visibleCounts = both(windows, (w) => visibleRendered(w).length);
226
260
  const budgetCounts = both(windows, (w) => withBudget(w).length);
227
- const mk = (vals, lots, dir, twin, nullNote = null) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote);
261
+ const mk = (vals, lots, dir, twin, nullNote = null, minSample = MIN_SAMPLE) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote, minSample);
228
262
  const planNote = hasPlans ? null : 'plan illisible ou absent';
229
263
  // Tokens par estimate et part de revue (R2, R3)
230
264
  const twinFirstTime = 'corrections.firstTime';
@@ -235,17 +269,22 @@ export function computePerf(input) {
235
269
  const counted = d.j.steps.filter((x) => x.counted !== undefined);
236
270
  if (!p || counted.length === 0)
237
271
  continue;
238
- const key = String(p.estimate);
239
- classes.set(key, [...(classes.get(key) ?? []), counted.reduce((sum, x) => sum + x.counted, 0)]);
272
+ // Un lot passé par la chaîne spec (écran ou back) coûte plus que le lot ordinaire de même estimate (mesuré le 12–18-10 : +150 k à l'estimate 0,5, +30 k à 1 ; 450 k est l'écart de budget, pas l'écart mesuré) : classe à part, sinon l'arrivée de ces lots dans une fenêtre passe pour un lot ordinaire plus lourd (L240).
273
+ const key = `${p.estimate}${d.j.steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
274
+ const kinds = new Map();
275
+ for (const x of counted)
276
+ kinds.set(carrierKind(x.kind), (kinds.get(carrierKind(x.kind)) ?? 0) + x.counted);
277
+ classes.set(key, [...(classes.get(key) ?? []), { total: counted.reduce((sum, x) => sum + x.counted, 0), kinds }]);
240
278
  }
241
279
  return classes;
242
280
  };
243
281
  const byClass = both(windows, tokensByClass);
244
282
  const byEstimate = {};
245
- const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort((a, b) => Number(a) - Number(b));
283
+ const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(byEstimateKey);
246
284
  for (const key of classKeys) {
247
- const vals = both(windows, (w) => median((w === windows.current ? byClass.current : byClass.previous).get(key) ?? []));
248
- byEstimate[key] = mk(vals, { current: byClass.current.get(key)?.length ?? 0, previous: byClass.previous.get(key)?.length ?? 0 }, 'up', twinFirstTime);
285
+ const lotsOfClass = both(windows, (w) => (w === windows.current ? byClass.current : byClass.previous).get(key) ?? []);
286
+ const vals = both(windows, (w) => median((w === windows.current ? lotsOfClass.current : lotsOfClass.previous).map((l) => l.total)));
287
+ byEstimate[key] = { ...mk(vals, { current: lotsOfClass.current.length, previous: lotsOfClass.previous.length }, 'up', twinFirstTime), carrier: carrierOf(lotsOfClass.current.map((l) => l.kinds), lotsOfClass.previous.map((l) => l.kinds)) };
249
288
  }
250
289
  const reviewShare = both(windows, (w) => {
251
290
  let review = 0;
@@ -278,7 +317,7 @@ export function computePerf(input) {
278
317
  const wallKinds = [...new Set(walls.filter((x) => inWindow(x.day, windows.current) || inWindow(x.day, windows.previous)).map((x) => x.kind))].sort((a, b) => kindRank(a) - kindRank(b) || a.localeCompare(b));
279
318
  for (const kind of wallKinds) {
280
319
  const v = both(windows, (w) => wallsOf(kind, w));
281
- durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime);
320
+ durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime, null, MIN_STEPS);
282
321
  }
283
322
  // Coût par lot livré (R8 à R10)
284
323
  const prices = input.prices ?? null;
@@ -336,7 +375,8 @@ export function computePerf(input) {
336
375
  causes,
337
376
  };
338
377
  const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
339
- const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped) };
378
+ const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped), dedicatedUx: mk(dedicatedUx, visibleCounts, null, null) };
379
+ const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null), ...precheckMeasure(windows, rendered) };
340
380
  const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
341
381
  const delays = {
342
382
  createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
@@ -357,19 +397,79 @@ export function computePerf(input) {
357
397
  return null;
358
398
  return [...plans.values()].flat().filter((l) => l.status === 'done' && l.finished !== undefined).reduce((sum, l) => sum + l.notes.filter((n) => isDay(n.date) && inWindow(n.date, w) && l.finished <= n.date && isQaBlockingNote(n.text)).length, 0);
359
399
  };
400
+ const breakdown = (w) => {
401
+ const b = { count: 0, byClass: { refus: 0, ci: 0, deploiement: 0, verification: 0, inconnue: 0 }, byProject: {} };
402
+ for (const [project, entries] of input.failures ?? []) {
403
+ for (const e of entries.filter((x) => inWindow(x.day, w))) {
404
+ const cls = e.cls ?? 'inconnue';
405
+ b.count += 1;
406
+ b.byClass[cls] += 1;
407
+ const p = (b.byProject[project] ??= {});
408
+ p[cls] = (p[cls] ?? 0) + 1;
409
+ }
410
+ }
411
+ return b;
412
+ };
360
413
  const quality = {
414
+ reds: both(windows, breakdown),
361
415
  redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
362
416
  qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
363
417
  };
364
- const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, tokens, delays, durations, cost, quality, orphans, warnings: [] };
418
+ const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, tokens, delays, durations, cost, quality, orphans, warnings: [] };
365
419
  const bottleneck = bottleneckOf(causes.current);
366
420
  for (const [key, ind] of indicatorLines(result))
367
421
  if (ind.warn)
368
- result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${DECISIONS[key.decision]}`);
422
+ result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${decisionOf(key, ind, result)}`);
369
423
  if (bottleneck)
370
424
  result.warnings.push(bottleneck);
371
425
  return result;
372
426
  }
427
+ const minutesOf = (s) => {
428
+ const from = Date.parse(s.started ?? '');
429
+ const to = Date.parse(s.ended ?? '');
430
+ return Number.isNaN(from) || Number.isNaN(to) || to < from ? 0 : (to - from) / 60_000;
431
+ };
432
+ /** Le contrôle préalable mesuré (L322) : répartition des verdicts, durée et tokens des sessions (local, Sonnet), gain des « oui », une ligne par lot de la fenêtre courante. */
433
+ function precheckMeasure(windows, rendered) {
434
+ const verdicts = (w) => {
435
+ const out = { oui: 0, partiel: 0, non: 0 };
436
+ for (const d of rendered(w))
437
+ if (d.j.precheck)
438
+ out[d.j.precheck]++;
439
+ return out;
440
+ };
441
+ const cost = (w) => {
442
+ const set = rendered(w);
443
+ const steps = set.flatMap((d) => d.j.steps.filter((s) => s.kind === 'precheck'));
444
+ const local = steps.filter((s) => s.model === 'local');
445
+ const sonnet = steps.filter((s) => s.model !== 'local');
446
+ // Un échec du local = une étape qui n'a pas fini `ok`, ou finie `ok` mais au rapport illisible (docs/perf.md : « ended without a usable report »).
447
+ const unusable = new Set(set.filter((d) => d.j.localUnreadable).flatMap((d) => d.j.steps.filter((s) => s.kind === 'precheck' && s.model === 'local')));
448
+ const implement = set.flatMap((d) => d.j.steps.filter((s) => s.kind === 'implement' && s.counted !== undefined).map((s) => s.counted));
449
+ const avoided = set.filter((d) => d.j.precheck === 'oui' && d.j.status === 'handed-back').length;
450
+ const typical = median(implement);
451
+ return {
452
+ local: { runs: local.length, failed: local.filter((s) => s.status !== 'ok' || unusable.has(s)).length, minutes: local.reduce((n, s) => n + minutesOf(s), 0) },
453
+ sonnet: { runs: sonnet.length, minutes: sonnet.reduce((n, s) => n + minutesOf(s), 0), tokens: sonnet.reduce((n, s) => n + (s.counted ?? 0), 0) },
454
+ avoided: { sessions: avoided, tokens: avoided === 0 ? 0 : typical === null ? null : avoided * typical },
455
+ };
456
+ };
457
+ const lots = rendered(windows.current).flatMap((d) => {
458
+ const steps = d.j.steps.filter((s) => s.kind === 'precheck');
459
+ if (steps.length === 0 && !d.j.precheck)
460
+ return [];
461
+ const local = steps.find((s) => s.model === 'local');
462
+ const sonnet = steps.filter((s) => s.model !== 'local');
463
+ return [{
464
+ project: d.j.project,
465
+ lot: d.j.lot,
466
+ verdict: d.j.precheck ?? null,
467
+ local: local ? { status: local.status === 'ok' && d.j.localUnreadable ? 'illisible' : local.status, minutes: minutesOf(local) } : null,
468
+ sonnet: sonnet.length ? { minutes: sonnet.reduce((n, s) => n + minutesOf(s), 0), tokens: sonnet.reduce((n, s) => n + (s.counted ?? 0), 0) } : null,
469
+ }];
470
+ });
471
+ return { verdicts: both(windows, verdicts), cost: both(windows, cost), lots };
472
+ }
373
473
  /** Goulot net (R19) : une cause pèse au moins la moitié des passes classées de la fenêtre courante, avec au moins dix passes classées. */
374
474
  function bottleneckOf(w) {
375
475
  const first = w.top[0];
@@ -386,12 +486,12 @@ const LABELS = {
386
486
  'test-manquant': 'test manquant',
387
487
  'faux-constat': 'faux constat de revue',
388
488
  };
389
- /** Les douze premières lignes de `indicatorLines` sont celles de L225 et les deux de qualité de livraison de L227 ; les suivantes sont celles de L226. */
390
- const LEGACY_LINES = 12;
489
+ /** Les quatorze premières lignes de `indicatorLines` sont celles de L225 (dont la part des lots visibles revus par une étape ux, L283, et la part de « oui » du précheck, L288) et les deux de qualité de livraison de L227 ; les suivantes sont celles de L226. */
490
+ const LEGACY_LINES = 14;
391
491
  /** Les indicateurs imprimés, dans l'ordre du tableau, avec leur étiquette et la décision qu'ils éclairent. */
392
492
  function indicatorLines(r) {
393
493
  const speed = [
394
- ...Object.entries(r.tokens.byEstimate).sort(([a], [b]) => Number(a) - Number(b)).map(([k, ind]) => [{ label: `tokens par lot (estimate ${k})`, kind: 'tokens', decision: 'tokens' }, ind]),
494
+ ...Object.entries(r.tokens.byEstimate).sort(([a], [b]) => byEstimateKey(a, b)).map(([k, ind]) => [{ label: `tokens par lot (estimate ${k})`, kind: 'tokens', decision: 'tokens' }, ind]),
395
495
  [{ label: 'part des tokens de revue', kind: 'share', decision: 'reviewShare' }, r.tokens.reviewShare],
396
496
  [{ label: 'délai création → done', kind: 'days', decision: 'createdToDone' }, r.delays.createdToDone],
397
497
  [{ label: 'délai done → livraison', kind: 'days', decision: 'doneToDelivered' }, r.delays.doneToDelivered],
@@ -407,6 +507,8 @@ function indicatorLines(r) {
407
507
  [{ label: 'part des lots à au moins 2 passes', kind: 'share', decision: 'atLeast2' }, r.corrections.atLeast2],
408
508
  [{ label: 'lots terminés hors vague', kind: 'share', decision: 'handedOut' }, r.interventions.handedOut],
409
509
  [{ label: 'sessions ux par lot visible', kind: 'num', decision: 'ux' }, r.interventions.ux],
510
+ [{ label: 'part des lots visibles revus par une étape ux', kind: 'share', decision: 'ux' }, r.interventions.dedicatedUx],
511
+ [{ label: 'part de « oui » du précheck', kind: 'share', decision: 'precheck' }, r.precheck.yes],
410
512
  [{ label: 'part conforme du premier coup', kind: 'share', decision: 'firstTime' }, r.corrections.firstTime],
411
513
  [{ label: 'défauts échappés', kind: 'count', decision: 'escaped' }, r.throughput.escaped],
412
514
  [{ label: 'livraisons rouges', kind: 'count', decision: 'redDeliveries' }, r.quality.redDeliveries],
@@ -436,6 +538,20 @@ const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %
436
538
  function describe(ind, kind) {
437
539
  return `${value(ind.current, kind)} (précédente ${value(ind.previous, kind)}${ind.change === null ? '' : `, ${signed(ind.change)}`})`;
438
540
  }
541
+ /**
542
+ * La décision d'un ⚠ (L312). Celle des tokens par lot cite l'étape qui porte la hausse : quand la part conforme du premier coup monte en même temps,
543
+ * la hausse est de la qualité payée à cette étape, plus une estimate fausse ; sinon l'estimate reste suspecte, avec l'étape.
544
+ */
545
+ function decisionOf(line, ind, r) {
546
+ const carrier = ind.carrier;
547
+ if (line.decision !== 'tokens' || !carrier)
548
+ return DECISIONS[line.decision];
549
+ const where = `la hausse est portée par l'étape ${carrier.kind} (+${value(carrier.delta, 'tokens')} par lot)`;
550
+ const { current, previous } = r.corrections.firstTime;
551
+ if (current !== null && previous !== null && current > previous)
552
+ return `${where} alors que la part conforme du premier coup monte : de la qualité payée, pas une estimate fausse ; juger cette étape (modèle, effort, passes) avant de recaler l'estimate`;
553
+ return `l'estimate est mal calibrée : ${where} : redécouper le lot ou recaler l'estimate et les budgets`;
554
+ }
439
555
  /** Les lignes que désigne le chemin `twin` d'un indicateur. */
440
556
  function twinsByPath(lines) {
441
557
  const at = (label) => lines.find(([l]) => l.label === label);
@@ -457,11 +573,28 @@ export function perfHeader(r) {
457
573
  if (!ind.warn)
458
574
  continue;
459
575
  const twin = ind.twin ? byPath.get(ind.twin) : undefined;
460
- header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${DECISIONS[line.decision]}`);
576
+ header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${decisionOf(line, ind, r)}`);
461
577
  }
462
578
  header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
463
579
  return header;
464
580
  }
581
+ const RED_LABELS = { refus: 'refus avant action', ci: 'CI', deploiement: 'déploiement', verification: 'vérification', inconnue: 'non classée' };
582
+ /** Deux lignes (par classe, par projet) des livraisons rouges de la fenêtre courante, la plus grosse d'abord ; aucune sans livraison rouge. */
583
+ function redBreakdownLines(b) {
584
+ if (b.count === 0)
585
+ return [];
586
+ const order = (a, c) => c[1] - a[1] || a[0].localeCompare(c[0]);
587
+ // À égalité, l'ordre des étapes de la livraison (refus, CI, déploiement, vérification), les lignes sans classe en dernier.
588
+ const rank = (k) => (k === 'inconnue' ? FAIL_CLASSES.length : FAIL_CLASSES.indexOf(k));
589
+ const classes = (counts) => Object.entries(counts).filter(([, n]) => n > 0).sort((a, c) => c[1] - a[1] || rank(a[0]) - rank(c[0])).map(([k, n]) => [RED_LABELS[k], n]);
590
+ const byProject = Object.entries(b.byProject)
591
+ .map(([name, counts]) => [name, Object.values(counts).reduce((a, n) => a + n, 0), classes(counts)])
592
+ .sort((a, c) => order([a[0], a[1]], [c[0], c[1]]));
593
+ return [
594
+ ` livraisons rouges par classe (fenêtre courante) : ${classes(b.byClass).map(([k, n]) => `${k} ${n}`).join(' · ')}`,
595
+ ` livraisons rouges par projet : ${byProject.map(([name, n, cl]) => `${name} ${n} (${cl.map(([k, c]) => `${k} ${c}`).join(', ')})`).join(' · ')}`,
596
+ ];
597
+ }
465
598
  /** Le tableau en texte : chaque indicateur de vitesse sur la même ligne que son jumeau de qualité (R8). */
466
599
  export function renderPerf(r) {
467
600
  const lines = indicatorLines(r);
@@ -485,7 +618,7 @@ export function renderPerf(r) {
485
618
  if (ind.note)
486
619
  parts.push(ind.note);
487
620
  if (ind.warn)
488
- parts.push(`→ ${DECISIONS[line.decision]}`);
621
+ parts.push(`→ ${decisionOf(line, ind, r)}`);
489
622
  if (extra?.[1].warn)
490
623
  parts.push(`→ ${DECISIONS[extra[0].decision]}`);
491
624
  return parts.join(' · ');
@@ -496,13 +629,19 @@ export function renderPerf(r) {
496
629
  out.push(row(l));
497
630
  };
498
631
  section('Débit', ['prêts', 'rendus par jour', 'livrés', 'part des rendus sur budget', 'défauts échappés']);
632
+ out.push(...redBreakdownLines(r.quality.reds.current));
499
633
  section('Passes de correction', ['médiane de passes de correction par lot', 'part des lots à au moins 2 passes', 'part conforme du premier coup']);
500
634
  const c = r.corrections.causes.current;
501
635
  const top = c.top.map((t, i) => `${i + 1}. ${LABELS[t.cause]} ${t.passes} (${pct(t.share)})`).join(' · ');
502
- out.push(` causes dominantes (${c.classified} passes classées) : ${top || 'aucune'} · non classé : ${c.unclassifiedShare === null ? 'n/d' : pct(c.unclassifiedShare)} · sans revue (tests rouges) : ${c.withoutFindings}`);
503
- section('Interventions du lead', ['lots terminés hors vague', 'sessions ux par lot visible']);
636
+ out.push(` causes dominantes (${c.classified} passes classées) : ${top || 'aucune'} · non classé : ${c.unclassifiedShare === null ? 'n/d' : pct(c.unclassifiedShare)} · sans revue (tests rouges) : ${c.withoutFindings}${c.unclassified > 0 ? ' · à classer : cadence corrections --classify' : ''}`);
637
+ const origins = Object.entries(c.byReview).sort(([a, x], [b, y]) => y - x || a.localeCompare(b));
638
+ if (origins.length > 0)
639
+ out.push(` origine des passes à constats : ${origins.map(([k, n]) => `${k} ${n}`).join(' · ')}`);
640
+ section('Interventions du lead', ['lots terminés hors vague', 'sessions ux par lot visible', 'part des lots visibles revus par une étape ux']);
504
641
  const h = r.interventions.handedOut.count;
505
642
  out.push(` lots rendus puis terminés hors vague : ${h.current ?? 'n/d'} (précédente ${h.previous ?? 'n/d'})`);
643
+ section('Contrôle préalable', ['part de « oui » du précheck']);
644
+ out.push(...precheckLines(r.precheck));
506
645
  out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
507
646
  for (const l of lines.slice(LEGACY_LINES))
508
647
  out.push(row(l));
@@ -515,6 +654,30 @@ export function renderPerf(r) {
515
654
  out.push('', `${r.orphans} lot(s) de journal sans plan`);
516
655
  return out;
517
656
  }
657
+ const plural = (n, one, many) => `${n} ${n > 1 ? many : one}`;
658
+ /** Le contrôle préalable de la fenêtre courante (L322) : répartition, coût local puis Sonnet, gain des « oui », une ligne par lot. Rien sans session de contrôle ni verdict. */
659
+ function precheckLines(p) {
660
+ const v = p.verdicts.current;
661
+ const c = p.cost.current;
662
+ if (v.oui + v.partiel + v.non + c.local.runs + c.sonnet.runs === 0)
663
+ return [];
664
+ const out = [` précheck : oui ${v.oui} · partiel ${v.partiel} · non ${v.non}`];
665
+ if (c.local.runs > 0)
666
+ out.push(` précheck local : ${plural(c.local.runs, 'session', 'sessions')}, ${plural(c.local.failed, 'échec', 'échecs')}, ${num(Math.round(c.local.minutes))} min`);
667
+ if (c.sonnet.runs > 0)
668
+ out.push(` précheck Sonnet : ${plural(c.sonnet.runs, 'session', 'sessions')}, ${value(c.sonnet.tokens, 'tokens')} tokens, ${num(Math.round(c.sonnet.minutes))} min`);
669
+ if (c.avoided.sessions > 0)
670
+ out.push(` gain des « oui » : ${plural(c.avoided.sessions, "session d'implémentation évitée", "sessions d'implémentation évitées")}${c.avoided.tokens === null ? ' (gain en tokens : n/d)' : ` (≈ ${value(c.avoided.tokens, 'tokens')} tokens)`}`);
671
+ for (const l of p.lots) {
672
+ const parts = [`${l.project}:${l.lot}`, l.verdict ?? 'sans verdict'];
673
+ if (l.local)
674
+ parts.push(`local ${l.local.status === 'ok' ? 'ok' : 'échec'} ${num(Math.round(l.local.minutes))} min`);
675
+ if (l.sonnet)
676
+ parts.push(`Sonnet ${num(Math.round(l.sonnet.minutes))} min, ${value(l.sonnet.tokens, 'tokens')} tokens`);
677
+ out.push(` ${parts.join(' · ')}`);
678
+ }
679
+ return out;
680
+ }
518
681
  /** Les plans des projets de `parent` (un niveau d'imbrication, R22) ; un plan illisible est sauté avec un avertissement (R16). */
519
682
  export function readPlans(parent, warn) {
520
683
  const plans = new Map();
@@ -544,12 +707,12 @@ export function readProjectDeliveries(parent, projects) {
544
707
  }
545
708
  return deliveries;
546
709
  }
547
- /** Les jours de livraison rouge de chaque projet du dossier, lus dans `<gitCommonDir>/cadence/deliveries-failed.log` (L227). Un projet sans journal compte 0 rouge ; un projet qui n'est pas un dépôt git est absent de la carte ; rien n'est créé. */
548
- export function readProjectFailedDeliveries(parent, projects) {
710
+ /** Les livraisons rouges (jour, classe, raison) de chaque projet du dossier, lues dans `<gitCommonDir>/cadence/deliveries-failed.log` (L227). Un projet sans journal compte 0 rouge ; un projet qui n'est pas un dépôt git est absent de la carte ; rien n'est créé. */
711
+ export function readProjectFailedEntries(parent, projects) {
549
712
  const failed = new Map();
550
713
  for (const name of projects) {
551
714
  try {
552
- failed.set(name, readFailedDeliveries(join(gitCommonDir(join(parent, name)), 'cadence')));
715
+ failed.set(name, readFailedEntries(join(gitCommonDir(join(parent, name)), 'cadence')));
553
716
  }
554
717
  catch {
555
718
  // pas un dépôt git : aucune livraison rouge pour ce projet
package/dist/plan.js CHANGED
@@ -6,7 +6,18 @@ export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
6
  export const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
7
7
  export class RafError extends Error {
8
8
  }
9
- export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate'];
9
+ /** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
10
+ export const RESOURCE_NAME = /^[a-z0-9_-]+$/;
11
+ export const RESOURCES_BAD_LIST = 'doit être une liste de noms de ressources';
12
+ export const invalidResource = (name) => `ressource invalide : ${name === '' ? '(vide)' : name} (lettres minuscules, chiffres, - et _)`;
13
+ /** Les noms de ressources de `--uses` ou de `raf uses` : chacun valide (`RafError` sinon), sans doublon, dans l'ordre donné. */
14
+ export function cleanUses(names) {
15
+ for (const n of names)
16
+ if (!RESOURCE_NAME.test(n))
17
+ throw new RafError(invalidResource(n));
18
+ return [...new Set(names)];
19
+ }
20
+ export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
10
21
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
11
22
  export const SCREENS = ['page', 'component', 'journey'];
12
23
  export function isOpen(status) {
@@ -61,6 +72,10 @@ export class Plan {
61
72
  get hasPublicField() {
62
73
  return !this.settings.format || !!this.settings.format.fields.public || this.read().lots.some((l) => l.public !== undefined);
63
74
  }
75
+ /** Un plan en lecture seule porte `visible` seulement si cadence.yaml le mappe (`plan.fields.visible`) ou si un lot l'écrit déjà sous ce nom. */
76
+ get hasVisibleField() {
77
+ return !this.settings.format || !!this.settings.format.fields.visible || this.read().lots.some((l) => l.visible);
78
+ }
64
79
  get readonly() {
65
80
  return !!this.settings.format;
66
81
  }
@@ -238,8 +253,8 @@ export class Plan {
238
253
  return this.enableGate('reviewSince', today);
239
254
  }
240
255
  /** `commit` : sha du dernier commit compté du lot (le plan ne lit pas git), null s'il n'en a aucun. */
241
- recordReview(lotId, verdict, today, commit, repos) {
242
- this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}) });
256
+ recordReview(lotId, verdict, today, commit, repos, reporte) {
257
+ this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}), ...(reporte ? { reporte } : {}) });
243
258
  }
244
259
  enableGate(key, today) {
245
260
  this.writable();
@@ -326,11 +341,15 @@ export class Plan {
326
341
  entry.every = opts.every;
327
342
  if (opts.after?.length)
328
343
  entry.after = opts.after;
344
+ if (opts.uses?.length)
345
+ entry.uses = cleanUses(opts.uses);
329
346
  entry.created = today;
330
347
  const node = this.doc.createNode(entry);
331
- const after = node.get('after');
332
- if (isSeq(after))
333
- after.flow = true;
348
+ for (const key of ['after', 'uses']) {
349
+ const seq = node.get(key);
350
+ if (isSeq(seq))
351
+ seq.flow = true;
352
+ }
334
353
  this.doc.get('lots').add(node);
335
354
  return id;
336
355
  }
@@ -341,6 +360,40 @@ export class Plan {
341
360
  node.flow = true;
342
361
  this.lotNode(lotId).set('repos', node);
343
362
  }
363
+ /** Acquitte l'écart `docs.sync` d'un lot (L317) : clé `docs_ok: { date, reason }`, la raison est obligatoire. Jamais sur une sous-tâche. */
364
+ setDocsOk(ref, reason, today) {
365
+ this.writable();
366
+ if (ref.includes('/'))
367
+ throw new RafError(`docs-ok se pose sur un lot, pas sur une sous-tâche : ${ref}`);
368
+ const why = reason.trim();
369
+ if (!why)
370
+ throw new RafError('docs-ok exige une raison : raf docs-ok <lot> --reason "pourquoi la documentation reste telle quelle"');
371
+ const node = this.doc.createNode({ date: today, reason: why });
372
+ node.flow = true;
373
+ this.lotNode(ref).set('docs_ok', node);
374
+ }
375
+ /** Remplace les ressources partagées d'un lot de tout statut (clé `uses:` en flow, avant `created:`) ; sans nom, retire la clé. Jamais sur une sous-tâche. */
376
+ setUses(ref, names) {
377
+ this.writable();
378
+ if (ref.includes('/'))
379
+ throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${ref}`);
380
+ const node = this.lotNode(ref);
381
+ const uses = cleanUses(names);
382
+ if (uses.length === 0) {
383
+ node.delete('uses');
384
+ return;
385
+ }
386
+ const list = this.doc.createNode(uses);
387
+ list.flow = true;
388
+ if (node.has('uses')) {
389
+ node.set('uses', list);
390
+ return;
391
+ }
392
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
393
+ const created = keys.indexOf('created');
394
+ const after = keys.indexOf('after');
395
+ node.items.splice(created >= 0 ? created : after >= 0 ? after + 1 : keys.length, 0, this.doc.createPair('uses', list));
396
+ }
344
397
  addTask(lotId, title) {
345
398
  this.writable();
346
399
  const lot = this.lotNode(lotId);
@@ -421,6 +474,21 @@ export class Plan {
421
474
  node.delete('started');
422
475
  this.note(ref, reason.trim() ? `remis en attente : ${reason.trim()}` : 'remis en attente', today);
423
476
  }
477
+ /** Remplace le titre d'un lot, de tout statut, et note l'ancien titre ; refuse un titre vide ou identique. */
478
+ setTitle(ref, text, today) {
479
+ this.writable();
480
+ if (ref.includes('/'))
481
+ throw new RafError(`raf title se pose sur un lot, pas une sous-tâche : ${ref}`);
482
+ const title = text.trim();
483
+ if (title === '')
484
+ throw new RafError('titre vide — raf title <id> "nouveau titre"');
485
+ const node = this.lotNode(ref);
486
+ const old = String(node.get('title') ?? '');
487
+ if (old === title)
488
+ throw new RafError(`${ref} porte déjà ce titre`);
489
+ node.set('title', title);
490
+ this.note(ref, `titre remplacé : « ${old} »`, today);
491
+ }
424
492
  /** Pose, remplace (texte) ou efface (null) le titre public d'un lot ; la clé suit `title`. */
425
493
  setPublic(ref, text) {
426
494
  this.writable();
@@ -460,6 +528,27 @@ export class Plan {
460
528
  const keys = node.items.map((p) => String(p.key?.value ?? p.key));
461
529
  node.items.splice(keys.indexOf('visible') + 1, 0, this.doc.createPair('screen', screen));
462
530
  }
531
+ /** Pose (true) ou retire (false) le `visible:` d'un lot existant ; retirer est refusé tant que le lot a un `screen:` (un écran est un changement visible). */
532
+ setVisible(ref, on) {
533
+ this.writable();
534
+ if (ref.includes('/'))
535
+ throw new RafError(`visible se pose sur un lot, pas une sous-tâche : ${ref}`);
536
+ const node = this.lotNode(ref);
537
+ const lot = this.lot(ref);
538
+ if (!on) {
539
+ if (lot.screen)
540
+ throw new RafError(`${ref} a screen: ${lot.screen} — retirer d'abord screen: (raf screen ${ref} none), un écran est un changement visible`);
541
+ node.delete('visible');
542
+ return;
543
+ }
544
+ if (node.has('visible')) {
545
+ node.set('visible', true);
546
+ return;
547
+ }
548
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
549
+ const at = Math.max(...['quickwin', 'estimate', 'status'].map((k) => keys.indexOf(k)));
550
+ node.items.splice(at + 1, 0, this.doc.createPair('visible', true));
551
+ }
463
552
  /** Pose (true) ou retire (false) le `spec:` d'un lot existant ; `screen: page` l'implique, donc on ne retire pas la spec d'un lot à page (`component` et `journey` suivent le chemin ordinaire depuis L209 : leur `spec: true` est la spec back). Renvoie false quand `screen: page` rendait la pose sans objet. */
464
553
  setSpec(ref, on) {
465
554
  this.writable();
@@ -565,6 +654,7 @@ function foreignLots(raw, format) {
565
654
  notes: typeof notes === 'string' ? [{ text: notes }] : notes,
566
655
  repos: pick('repos'),
567
656
  gate: pick('gate'),
657
+ goal: pick('goal'),
568
658
  }, problems);
569
659
  const parent = pick('parent');
570
660
  return { lot, parent: parent == null ? undefined : String(parent) };
@@ -578,7 +668,7 @@ function foreignLots(raw, format) {
578
668
  into.problems.push(...lot.problems.map((p) => `${lot.id} : ${p}`));
579
669
  }
580
670
  else {
581
- lots.push(lot);
671
+ lots.push(parent ? { ...lot, parent } : lot);
582
672
  }
583
673
  }
584
674
  return lots;
@@ -622,6 +712,13 @@ function normalizeLot(raw, problems = []) {
622
712
  };
623
713
  const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
624
714
  const last = asDay('last');
715
+ let uses;
716
+ if (raw.uses != null) {
717
+ if (Array.isArray(raw.uses) && raw.uses.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
718
+ uses = [...new Set(raw.uses)];
719
+ else
720
+ problems.push(`uses ${RESOURCES_BAD_LIST}`);
721
+ }
625
722
  let screen;
626
723
  if (raw.screen != null) {
627
724
  if (SCREENS.includes(raw.screen))
@@ -639,6 +736,8 @@ function normalizeLot(raw, problems = []) {
639
736
  problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
640
737
  }
641
738
  const gate = readGate(raw.gate, problems);
739
+ const okRaw = raw.docs_ok;
740
+ const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
642
741
  return {
643
742
  id: String(raw.id),
644
743
  title: String(raw.title ?? ''),
@@ -650,9 +749,11 @@ function normalizeLot(raw, problems = []) {
650
749
  ...(spec ? { spec } : {}),
651
750
  ...(gate ? { gate } : {}),
652
751
  ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
752
+ ...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
653
753
  ...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
654
754
  ...(last ? { last } : {}),
655
755
  after,
756
+ ...(uses?.length ? { uses } : {}),
656
757
  created: asDay('created'),
657
758
  started: asDay('started'),
658
759
  finished: asDay('finished'),
@@ -669,6 +770,7 @@ function normalizeLot(raw, problems = []) {
669
770
  ...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
670
771
  ...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
671
772
  ...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
773
+ ...(docsOk ? { docsOk } : {}),
672
774
  problems,
673
775
  };
674
776
  }
@@ -705,5 +807,8 @@ function asVerdict(raw) {
705
807
  if (repos.length)
706
808
  verdict.repos = Object.fromEntries(repos);
707
809
  }
810
+ const r = v.reporte;
811
+ if (r && typeof r === 'object' && !Array.isArray(r) && r.de != null && r.vers != null)
812
+ verdict.reporte = { de: String(r.de), vers: String(r.vers), patchId: String(r.patchId ?? '') };
708
813
  return verdict;
709
814
  }
package/dist/proc.js CHANGED
@@ -12,6 +12,12 @@ let dying = false;
12
12
  * ses `trap` et le nettoyage de git (index.lock) ont ce temps pour finir avant le kill.
13
13
  */
14
14
  export const SIGNAL_GRACE_MS = 2_000;
15
+ /**
16
+ * Attente, après la sortie de la commande racine, d'un signal de terminaison encore en route vers cadence tant que des
17
+ * processus suivis vivent ou que la racine est morte d'un tel signal : le terminal l'envoie au groupe entier, la racine
18
+ * peut être traitée la première.
19
+ */
20
+ export const SIGNAL_PENDING_MS = 2_000;
15
21
  async function onSignal(sig) {
16
22
  if (dying)
17
23
  return; // un second signal pendant le nettoyage n'interrompt rien