@sentropic/track 0.87.0 → 0.88.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.
@@ -3,7 +3,7 @@ import { buildDirectives, decisionNeedsFocus, dispatchQueueOf, keystoneOf, } fro
3
3
  // Unified report presentation (spec 2026-07-11) — the SINGLE enum→French lexicon the cockpit shares, so
4
4
  // the two surfaces can never re-word apart. The terminal composes its own `<nature> (<actor>): <clause>`
5
5
  // sentence but sources the canonical action clause + scope label from here.
6
- import { directiveScopeLabelFr as directiveScopeLabel, stepActionFr } from './friendly.js';
6
+ import { directiveScopeLabelFr as directiveScopeLabel, gatePhraseFr, stepActionFr } from './friendly.js';
7
7
  const BACKSLASH = String.fromCharCode(92);
8
8
  // Markdown metacharacters escaped in `md` titles so a user title can't inject formatting.
9
9
  const MD_META = new Set([
@@ -29,16 +29,53 @@ export function cleanDisplayText(s) {
29
29
  }
30
30
  return out.trim();
31
31
  }
32
- /** A display-safe title: control-normalized for text, plus markdown-metacharacter-escaped for md. */
33
- export function displayText(s, format) {
34
- const t = cleanDisplayText(s);
35
- if (format !== 'md')
36
- return t;
32
+ /** Backslash-escape every markdown metacharacter. Does NOT trim the caller owns normalization. */
33
+ function escapeMdMeta(s) {
37
34
  let out = '';
38
- for (const ch of t)
35
+ for (const ch of s)
39
36
  out += MD_META.has(ch) ? BACKSLASH + ch : ch;
40
37
  return out;
41
38
  }
39
+ /** A display-safe title: control-normalized for text, plus markdown-metacharacter-escaped for md. */
40
+ export function displayText(s, format) {
41
+ const t = cleanDisplayText(s);
42
+ return format === 'md' ? escapeMdMeta(t) : t;
43
+ }
44
+ /**
45
+ * The row handle token `[n.m]` — ONE definition, three consumers: the builder emits it, the renderer
46
+ * keeps it OUT of the markdown-escaped span, and the parity test extracts it. It is exempt from escaping
47
+ * because it is MACHINE-GENERATED, never user content: `track report --resolve <handle>` is the documented
48
+ * path from a rendered row to an action, and a handle a machine has to unescape first is a handle that
49
+ * breaks that path in exactly one of the three formats.
50
+ *
51
+ * The exemption cannot become an injection route: only the literal token matches, and every other
52
+ * character around it — including the `(` `)` a markdown link would need — is still escaped.
53
+ */
54
+ export const HANDLE_TOKEN_SOURCE = String.raw `\[\d+\.\d+\]`;
55
+ /** Fresh instance per call: a shared `g`-flagged regex carries `lastIndex` state between callers. */
56
+ export function handleTokenRegex() {
57
+ return new RegExp(HANDLE_TOKEN_SOURCE, 'gu');
58
+ }
59
+ /**
60
+ * A display-safe TABLE CELL. Same guarantee as `displayText` for every user-originated fragment, with the
61
+ * machine-generated handle token passed through verbatim so all three formats yield the SAME handle set.
62
+ */
63
+ export function displayCell(s, format) {
64
+ // Criterion 27 — an explicit `\n` is an EDITORIAL break (one idea per line) and survives to the
65
+ // renderer; every other control character still collapses to a space.
66
+ return s
67
+ .split('\n')
68
+ .map((line) => {
69
+ const t = cleanDisplayText(line);
70
+ if (format !== 'md')
71
+ return t;
72
+ return t
73
+ .split(new RegExp(`(${HANDLE_TOKEN_SOURCE})`, 'u'))
74
+ .map((part, index) => (index % 2 === 1 ? part : escapeMdMeta(part)))
75
+ .join('');
76
+ })
77
+ .join('\n');
78
+ }
42
79
  function clean(s) {
43
80
  return cleanDisplayText(s);
44
81
  }
@@ -128,14 +165,6 @@ function actionDisposition(r) {
128
165
  return 'terminer ou expliciter blocage';
129
166
  return 'exécuter prochain incrément';
130
167
  }
131
- function decisionDisposition(d) {
132
- if (decisionNeedsFocus(d)) {
133
- return `focus décision HTML conseillé: track focus ${d.id} --workspace ${d.workspace} --format html`;
134
- }
135
- if ((d.openQuestionCount ?? 0) > 0)
136
- return 'répondre aux questions ouvertes puis trancher';
137
- return `choisir une option enregistrée puis la régler durablement avec track decision select ${d.id} <option-id> --outcome <go|no-go>`;
138
- }
139
168
  function cell(s) {
140
169
  return clean(s).replaceAll('|', '¦');
141
170
  }
@@ -168,23 +197,36 @@ function wrapCell(s, width) {
168
197
  lines.push(line);
169
198
  return lines;
170
199
  }
171
- function table(headers, rows) {
200
+ function defaultCap(header) {
201
+ const k = header.toLowerCase();
202
+ if (k.includes('sujet') || k.includes('items') || k.includes('à faire'))
203
+ return 72;
204
+ if (k.includes('préconisation') || k.includes('dernières actions'))
205
+ return 64;
206
+ if (k.includes('prochaine action'))
207
+ return 44;
208
+ if (k.includes('complexité') || k.includes('notes') || k.includes('dropped'))
209
+ return 38;
210
+ if (k.includes('scope'))
211
+ return 42;
212
+ if (k === 'wp')
213
+ return 30;
214
+ if (k.includes('wp'))
215
+ return 42;
216
+ if (k === 'av.')
217
+ return 6;
218
+ if (k.includes('bloqué'))
219
+ return 18;
220
+ return 24;
221
+ }
222
+ function table(headers, rows, capOverrides) {
172
223
  // Terminal-first padded table: aligned columns, bounded width, MULTI-LINE cells.
173
224
  // No ellipsis: long content wraps inside the column so the report stays readable and complete enough.
174
- const caps = headers.map((h) => {
175
- const k = h.toLowerCase();
176
- if (k.includes('sujet') || k.includes('items') || k.includes('à faire'))
177
- return 72;
178
- if (k.includes('préconisation') || k.includes('dernières actions'))
179
- return 64;
180
- if (k.includes('complexité') || k.includes('notes') || k.includes('dropped'))
181
- return 38;
182
- if (k.includes('scope') || k.includes('wp'))
183
- return 42;
184
- return 24;
185
- });
225
+ const caps = headers.map((h, i) => capOverrides?.[i] ?? defaultCap(h));
186
226
  const head = headers.map((h, i) => cell(h).slice(0, caps[i]));
187
- const wrappedRows = rows.map((row) => headers.map((_, i) => wrapCell(row[i] ?? '', caps[i])));
227
+ // Criterion 27 a cell is written like an editor writes: one idea per line. An explicit `\n` is a
228
+ // break the reader asked for; wrapping only handles what overflows a line.
229
+ const wrappedRows = rows.map((row) => headers.map((_, i) => (row[i] ?? '').split('\n').flatMap((line) => (line.trim() === '' ? [''] : wrapCell(line, caps[i])))));
188
230
  const widths = head.map((h, i) => Math.min(caps[i], Math.max(h.length, ...wrappedRows.flatMap((r) => r[i]).map((v) => v.length))));
189
231
  // Padding aligns interior columns; remove only terminal padding so reports and committed fixtures do not
190
232
  // carry invisible trailing whitespace.
@@ -200,9 +242,36 @@ function table(headers, rows) {
200
242
  out.pop();
201
243
  return out;
202
244
  }
245
+ /**
246
+ * A BOX-DRAWN table (the shape the owner validated for DÉCISIONS). Cells may carry explicit `\n`
247
+ * line breaks — one alternative per line — so a recommendation can sit on the line of its own option.
248
+ * Deterministic: widths are derived from the content, capped per column, and long lines wrap.
249
+ */
250
+ function drawTable(headers, rows, caps, center = []) {
251
+ const split = (value, width) => value.split('\n').flatMap((line) => (line.trim() === '' ? [''] : wrapCell(line, width)));
252
+ const wrapped = rows.map((row) => headers.map((_, i) => split(row[i] ?? '', caps[i])));
253
+ const widths = headers.map((h, i) => Math.min(caps[i], Math.max(cell(h).length, ...wrapped.flatMap((r) => r[i]).map((v) => v.length), 1)));
254
+ const pad = (value, i) => center[i] === true
255
+ ? ' '.repeat(Math.floor((widths[i] - value.length) / 2)) +
256
+ value +
257
+ ' '.repeat(widths[i] - value.length - Math.floor((widths[i] - value.length) / 2))
258
+ : value.padEnd(widths[i]);
259
+ const rule = (left, mid, right) => left + widths.map((w) => '─'.repeat(w + 2)).join(mid) + right;
260
+ const line = (cells) => '│ ' + cells.map((v, i) => pad(v, i)).join(' │ ') + ' │';
261
+ const out = [rule('┌', '┬', '┐'), line(headers.map((h) => cell(h))), rule('├', '┼', '┤')];
262
+ wrapped.forEach((row, index) => {
263
+ const height = Math.max(...row.map((cellLines) => cellLines.length));
264
+ for (let y = 0; y < height; y++)
265
+ out.push(line(row.map((cellLines) => cellLines[y] ?? '')));
266
+ if (index < wrapped.length - 1)
267
+ out.push(rule('├', '┼', '┤'));
268
+ });
269
+ out.push(rule('└', '┴', '┘'));
270
+ return out;
271
+ }
203
272
  /**
204
273
  * Directive fallback for repos that have no WP containers yet. This is intentionally NOT the exhaustive
205
- * flat dump: it keeps the “decision/action recommendation” spirit while `--flat` remains available for
274
+ * flat dump: it keeps deterministic action guidance while `--flat` remains available for
206
275
  * the full bucket listing.
207
276
  */
208
277
  export function formatActionReport(report, format) {
@@ -220,20 +289,13 @@ export function formatActionReport(report, format) {
220
289
  lines.push(h('SYNTHÈSE'));
221
290
  lines.push(...table(['fait', 'à-faire', 'attendus', 'dropped', 'décisions pending'], [[String(done.length), String(todo.length), String(awaited.length), String(dropped.length), String(pendingDecisions.length)]]));
222
291
  lines.push('');
223
- lines.push(h('DÉCISIONS/ACTIONS'));
292
+ lines.push(h('ACTIONS DÉRIVÉES'));
224
293
  const candidates = [...awaited, ...todo];
225
294
  const actionRows = [];
226
295
  const focusCount = pendingDecisions.filter(decisionNeedsFocus).length;
227
296
  if (focusCount >= 2 || pendingDecisions.length >= 4) {
228
297
  actionRows.push(['focus', 'décisions accumulées', 'focus (humain): lancer focus HTML local; régler toute option choisie avec track decision select']);
229
298
  }
230
- for (const d of pendingDecisions) {
231
- actionRows.push([
232
- d.decisionKind,
233
- title(d.title, format),
234
- `décision (${d.accountable ?? 'owner'}): ${decisionDisposition(d)}`,
235
- ]);
236
- }
237
299
  for (const r of candidates) {
238
300
  actionRows.push([
239
301
  r.bucket,
@@ -241,14 +303,14 @@ export function formatActionReport(report, format) {
241
303
  `action (${r.engagementRef !== undefined ? 'h2a/subagent' : 'local/subagent'}): ${actionDisposition(r)}`,
242
304
  ]);
243
305
  }
244
- lines.push(...table(['scope/gate', 'sujet', 'préconisation'], actionRows.length > 0 ? actionRows : [['-', 'aucune décision/action ouverte', '-']]));
306
+ lines.push(...table(['scope/gate', 'sujet', 'préconisation'], actionRows.length > 0 ? actionRows : [['-', 'aucune action dérivée ouverte', '-']]));
245
307
  lines.push('');
246
308
  if (structured.length > 0) {
247
309
  lines.push(h('DÉCISIONS'));
248
310
  lines.push(...table(['dossier', 'alternatives enregistrées', 'recommandation / règlement'], structured.map((d) => [
249
311
  `${d.id} — ${title(d.title, format)} (${d.outcome})`,
250
312
  d.options?.map((option) => `${option.id}: ${title(option.title, format)} — ${title(option.summary, format)}`).join(' / ') ?? '-',
251
- `recommandée:${d.recommendation?.optionId ?? '-'}${d.selectedOptionId !== undefined ? `; sélectionnée:${d.selectedOptionId}` : ''}`,
313
+ `recommandée:${d.recommendation?.optionId ?? '-'}${d.selectedOptionId !== undefined ? `; sélectionnée:${d.selectedOptionId}` : d.outcome === 'pending' ? `; régler avec track decision select ${d.id} <option-id> --outcome <go|no-go>` : ''}`,
252
314
  ])));
253
315
  lines.push('');
254
316
  }
@@ -448,7 +510,181 @@ export function directivePhrase(d) {
448
510
  const suffix = d.step.code === 'resolve-external-blocker' || d.step.code === 'finish-increment' ? on() : '';
449
511
  return `action (${mode}): ${stepActionFr(d.step.code)}${suffix}`;
450
512
  }
451
- export function buildWpConductorView(tree, decisions = [], outsideRollup = [], totalScope = 'global') {
513
+ // ---- the four owner-facing sections (spec 2026-07-29) ----------------------------------------------
514
+ // FAIT · À-FAIRE · DÉCISIONS · RECOMMANDATION. Nothing else is a top-level section: what mattered in the
515
+ // old `À-FAIRE SANS WP` / `HORS ROLLUP` / `À INSTRUIRE` / `HISTORIQUE NON STRUCTURÉ` / `ACTIONS DÉRIVÉES`
516
+ // tables is folded INTO the four, by title — never deleted (criteria 17/18).
517
+ /** No blockage is RECORDED. Never emitted when a gate exists (criterion 19). */
518
+ const NO_GATE = '—';
519
+ /** No next action, and none is owed: the row is gated on a decision (criterion 14, as scoped). */
520
+ const NO_ACTION = '—';
521
+ /** A gate → the SHORT token `bloqué` carries. A decision gate is replaced by its D/Q number. */
522
+ const GATE_TOKEN = {
523
+ 'decision-pending': 'décision',
524
+ 'engagement-pending': 'h2a',
525
+ 'external-dependency': 'dépendance',
526
+ 'linked-dependency': 'dépendance',
527
+ 'manual-blocker': 'blocage',
528
+ 'spec-not-ready': 'spec',
529
+ 'acceptance-failed': 'recette KO',
530
+ 'acceptance-stale': 'recette',
531
+ 'priority-missing': 'priorité',
532
+ };
533
+ /** DONE leaves under a node, most recent first (ULIDs sort by time), for FAIT's `dernières actions`. */
534
+ function recentDoneLeaves(node) {
535
+ const out = [];
536
+ const walk = (n) => {
537
+ for (const l of n.leaves)
538
+ if (l.bucket === 'DONE')
539
+ out.push(l);
540
+ for (const c of n.children)
541
+ walk(c);
542
+ };
543
+ walk(node);
544
+ return out.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
545
+ }
546
+ const LAST_ACTIONS_SHOWN = 3;
547
+ /**
548
+ * FAIT's third column (criteria 3/4/22/26/27).
549
+ *
550
+ * When the scope's completions fit, they ARE the statement — one per line (27), not a `·`-joined block.
551
+ *
552
+ * When they do not, the renderer must NOT emit the titles: a chronological list of item titles is a
553
+ * commit log translated into French, which is precisely the shape criterion 26 forbids. It has no reading
554
+ * of them to offer, so it says what it owes and what writing it takes — three lines, one idea each. That
555
+ * cell is an instruction to the agent, never a result.
556
+ */
557
+ function lastActionsCell(titles) {
558
+ if (titles.length === 0)
559
+ return 'aucune action enregistrée';
560
+ if (titles.length <= LAST_ACTIONS_SHOWN)
561
+ return titles.join('\n');
562
+ return [
563
+ `bilan à écrire : ${titles.length} livraisons sur la fenêtre, titres seuls dans le projeté.`,
564
+ 'Écrire par la finalité — la capacité atteinte, la classe de problème fermée ; chiffres en appui.',
565
+ ].join('\n');
566
+ }
567
+ // ---- `prochaine action` (criterion 20) -----------------------------------------------------------
568
+ // The gate-derived clause (`Terminer l'incrément en cours`, `Rédiger la spécification`) names the CLASS of
569
+ // the work, never the work. Twenty rows, five distinct sentences, zero information — that is a template,
570
+ // not a recommendation, and this renderer must stop presenting one as the other. The class is not lost: it
571
+ // is exactly what the `bloqué` column already says, under a label that is honest about being a class.
572
+ //
573
+ // So the deterministic layer emits a marker that CANNOT be mistaken for a recommendation, and the skill
574
+ // makes the agent replace it — on the focus rows only, by opening the item — with the concrete gesture.
575
+ /** A focus row: the agent MUST open the item and name the gesture before this report is served. */
576
+ const NEXT_ACTION_TO_INSTRUCT = 'à instruire : ouvrir l’item et nommer le geste';
577
+ /** A non-focus row: the report says plainly that the action was not instructed, rather than faking one. */
578
+ const NEXT_ACTION_NOT_INSTRUCTED = 'non instruite';
579
+ /** Criterion 24 — what would make an unanswerable dossier answerable. Specific, not a gate class. */
580
+ const NEXT_ACTION_STRUCTURE_DOSSIER = 'à structurer : enregistrer options + recommandation';
581
+ /** How many leading rows are the focus — the same five the À-FAIRE ordering line already names. */
582
+ const FOCUS_ROWS = 5;
583
+ /**
584
+ * Criterion 25 — beyond this many days the window is LONG, and the WP is the unit of reading: a sub-WP is
585
+ * implementation detail that inflates the table and blurs the reading by theme. Sub-levels are aggregated
586
+ * into their parent, never listed beside it. They come back on a short window or on explicit owner
587
+ * request (`--sub-wp`).
588
+ */
589
+ const LONG_WINDOW_DAYS = 14;
590
+ function windowDays(period) {
591
+ if (period.from === undefined || period.to === undefined)
592
+ return undefined;
593
+ const from = Date.parse(period.from);
594
+ const to = Date.parse(period.to);
595
+ return Number.isNaN(from) || Number.isNaN(to) ? undefined : Math.round((to - from) / 86_400_000);
596
+ }
597
+ /**
598
+ * How much of an item's RECORDED body the `à faire` cell shows. Tight on purpose: one clause, enough to
599
+ * tell the owner what the row is about, cut at a word boundary and always marked `extrait :` so nobody
600
+ * reads it as the full record — the same honesty the old `extrait` column applied.
601
+ */
602
+ const TODO_EXCERPT_MAX = 100;
603
+ /** `undefined` for an absent/blank body — a bare title is then the HONEST render, not a gap to fill. */
604
+ export function todoExcerpt(body) {
605
+ const cleaned = body === undefined ? undefined : clean(body);
606
+ if (cleaned === undefined || cleaned === '')
607
+ return undefined;
608
+ if (cleaned.length <= TODO_EXCERPT_MAX)
609
+ return cleaned;
610
+ const cut = cleaned.slice(0, TODO_EXCERPT_MAX);
611
+ const boundary = cut.lastIndexOf(' ');
612
+ return `${(boundary > TODO_EXCERPT_MAX / 2 ? cut.slice(0, boundary) : cut).trimEnd()}…`;
613
+ }
614
+ /** The `prochaine action` values this renderer may emit. A test pins that no gate clause joins them. */
615
+ export const DETERMINISTIC_NEXT_ACTIONS = [
616
+ NEXT_ACTION_TO_INSTRUCT, NEXT_ACTION_NOT_INSTRUCTED, NEXT_ACTION_STRUCTURE_DOSSIER, '—',
617
+ ];
618
+ /**
619
+ * Criterion 20, made checkable. The owner's judgement — is the sentence RIGHT — is out of reach of any
620
+ * test; these two mechanical failures are not. A contextual report passes when every focus row has been
621
+ * instructed AND no substantive action repeats more than twice or equals a gate clause.
622
+ *
623
+ * The renderer's own markers are counted as `uninstructed`, never as violations: they are the honest
624
+ * statement that the work is still owed, which is exactly what the report must say until it is done.
625
+ */
626
+ export function auditNextActions(values, gateClauses) {
627
+ const marker = new Set(DETERMINISTIC_NEXT_ACTIONS);
628
+ const substantive = values.filter((value) => !marker.has(value));
629
+ const counts = new Map();
630
+ for (const value of substantive)
631
+ counts.set(value, (counts.get(value) ?? 0) + 1);
632
+ const repeated = [...counts.entries()].filter(([, n]) => n > 2).map(([value]) => value);
633
+ const bare = (value) => value.replace(/^(?:action|engagement|décision) \([^)]*\)\s*:\s*/u, '');
634
+ const gates = new Set(gateClauses);
635
+ const hits = substantive.filter((value) => gates.has(bare(value)));
636
+ const uninstructed = values.filter((value) => value === NEXT_ACTION_TO_INSTRUCT).length;
637
+ return { uninstructed, repeated, gateClauses: [...new Set(hits)], ok: repeated.length === 0 && hits.length === 0 };
638
+ }
639
+ /** `2026-07-29T11:02:03.000Z` → `2026-07-29`. UTC, so the header is TZ-independent and reproducible. */
640
+ function isoDate(value) {
641
+ if (value === undefined)
642
+ return undefined;
643
+ const parsed = new Date(value);
644
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString().slice(0, 10);
645
+ }
646
+ /** Criterion 21 — the period, always bounded, always read from the log (plus the caller's clock). */
647
+ function buildPeriod(meta) {
648
+ const from = isoDate(meta.logFrom);
649
+ const now = isoDate(meta.now);
650
+ const last = isoDate(meta.logTo);
651
+ const to = now ?? last;
652
+ const toSource = now !== undefined ? 'now' : last !== undefined ? 'last-event' : 'unknown';
653
+ const suffix = toSource === 'last-event' ? ' (intégralité du journal, borne haute = dernier événement)' : ' (intégralité du journal)';
654
+ const label = from === undefined || to === undefined
655
+ ? 'période : journal vide (aucun événement enregistré)'
656
+ : `période : ${from} → ${to}${suffix}`;
657
+ return {
658
+ ...(from !== undefined ? { from } : {}),
659
+ ...(to !== undefined ? { to } : {}),
660
+ toSource,
661
+ label,
662
+ };
663
+ }
664
+ const OPTION_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
665
+ const optionLetter = (index) => OPTION_LETTERS[index] ?? `#${index + 1}`;
666
+ /**
667
+ * The DÉCISIONS column widths, SHARED between the builder and the renderer. The builder wraps the
668
+ * `alternatives` cell itself so it can emit the `préco` cell with exactly matching blank lines: that is
669
+ * what puts each recommendation ON THE LINE OF ITS OWN OPTION instead of on some continuation line.
670
+ */
671
+ const DECISION_CAPS = [6, 40, 46, 14];
672
+ /** `[1,2,3,5]` → `D1–D3 · D5`: the compact form the validated report uses for a run of decisions. */
673
+ function compactRefs(refs) {
674
+ const uniq = [...new Set(refs)];
675
+ const numeric = uniq.filter((r) => /^D\d+$/u.test(r)).map((r) => Number(r.slice(1))).sort((a, b) => a - b);
676
+ const other = uniq.filter((r) => !/^D\d+$/u.test(r));
677
+ const parts = [];
678
+ for (let i = 0; i < numeric.length;) {
679
+ let j = i;
680
+ while (j + 1 < numeric.length && numeric[j + 1] === numeric[j] + 1)
681
+ j++;
682
+ parts.push(j - i >= 2 ? `D${numeric[i]}–D${numeric[j]}` : numeric.slice(i, j + 1).map((n) => `D${n}`).join(' · '));
683
+ i = j + 1;
684
+ }
685
+ return [...parts, ...other].join(' · ');
686
+ }
687
+ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], totalScope = 'global', meta = {}) {
452
688
  const wpName = (n) => `${n.label} · ${clean(stripWpPrefix(n.title))}`;
453
689
  const totals = wpTotals(tree, outsideRollup);
454
690
  const wpNodes = [];
@@ -459,93 +695,566 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
459
695
  }
460
696
  };
461
697
  collectWpNodes(tree);
462
- // preconisation-actionnable (DESIGN §4): the DÉCISIONS/ACTIONS table + generalRecommendation are now
463
- // DERIVED from the directive set (each directive one row, phrase rendered, never stored). FAIT/À-FAIRE
464
- // stay the same rollup-driven tables (unchanged back-compat).
698
+ // ---- criterion 25: the reading unit ---------------------------------------------------------------
699
+ // On a long window the WP is the unit and a sub-WP is implementation detail. Sub-levels are AGGREGATED
700
+ // into their root their leaves already roll up (`openLeaves`/`recentDoneLeaves` walk children), so
701
+ // nothing is lost; only their row disappears. The aggregation is DECLARED in the header, like every
702
+ // other compression in this report.
703
+ const period = buildPeriod(meta);
704
+ const days = windowDays(period);
705
+ const subWpDetail = meta.subWp === true || (days !== undefined && days < LONG_WINDOW_DAYS);
706
+ const rowNodes = subWpDetail ? wpNodes : [...tree];
707
+ const subNodes = wpNodes.filter((n) => !rowNodes.includes(n));
708
+ /** Every node whose content merges into `n` when sub-levels are aggregated (`n` itself included). */
709
+ const branchOf = (n) => {
710
+ const out = [];
711
+ const walk = (node) => {
712
+ out.push(node);
713
+ for (const child of node.children)
714
+ walk(child);
715
+ };
716
+ walk(n);
717
+ return subWpDetail ? [n] : out;
718
+ };
465
719
  const directives = buildDirectives(tree, decisions);
466
720
  const dispatchQueue = dispatchQueueOf(directives);
467
721
  const keystone = keystoneOf(tree);
468
- const { structured: structuredDecisions, legacyPending, legacySettled } = classifyDecisions(decisions);
469
- const legacyIds = new Set(legacyPending.map((d) => d.id));
470
- // A legacy dossier may still have a decision blocker on a leaf. Its directive is deliberately
471
- // withheld from the owner-facing decision section: it belongs in À INSTRUIRE until the recorded
472
- // option/recommendation model is populated by an authenticated revision.
473
- const displayDirectives = directives.filter((d) => !legacyIds.has(d.gate?.ref ?? ''));
474
- const humanDecisions = displayDirectives.filter((d) => d.mode === 'human-decision');
475
- const focusNeeded = humanDecisions.filter((d) => d.step.code === 'focus-decision').length;
476
- const generalRecommendation = focusNeeded >= 2 || humanDecisions.length >= 4
477
- ? 'Prévoir un temps de focus HTML pour trancher les décisions accumulées, puis reprendre les WP par premier item ouvert.'
478
- : 'Avancer par premier item ouvert, enregistrer preuve/acceptance, et escalader uniquement les décisions réellement bloquantes.';
722
+ const { structured: structuredDecisions, legacyPending } = classifyDecisions(decisions);
723
+ // ---- decision numbering (criteria 16/23/24) ------------------------------------------------------
724
+ // 16 a D-number is RESERVED for a dossier whose options AND recommendation are stored and still
725
+ // pending: those are the only ones an owner can answer with a letter.
726
+ // 23 DÉCISIONS is the surface where the owner DECIDES. A settled dossier has nothing to answer; it
727
+ // crowds out the ones still waiting and is already visible where it counts (a freed `bloqué`
728
+ // cell, or FAIT if it produced something). It leaves the report and is counted among omissions.
729
+ // 24 a pending dossier with no stored options cannot be answered either. It is not dressed up as a
730
+ // choice: it appears in À-FAIRE as the work of making it answerable.
731
+ const structuredPending = structuredDecisions.filter((d) => d.outcome === 'pending');
732
+ const settledDecisions = decisions.filter((d) => d.outcome !== 'pending');
733
+ const decisionRef = new Map();
734
+ structuredPending.forEach((d, i) => decisionRef.set(d.id, `D${i + 1}`));
735
+ legacyPending.forEach((d, i) => decisionRef.set(d.id, `Q${i + 1}`));
736
+ const isPending = new Set(decisions.filter((d) => d.outcome === 'pending').map((d) => d.id));
737
+ // ---- handles (criteria 10b/10c) -----------------------------------------------------------------
738
+ // Handles are POSITIONAL WITHIN THIS REPORT (`[row.item]`), assigned AFTER À-FAIRE is ordered. Leg A
739
+ // established that no content-derived handle can be stable across runs — ordering, titles and WP
740
+ // membership all move — so the identifier is RELOCATED, not invented: the resolution block at the end
741
+ // of the page maps every emitted handle to its item id, and no ULID enters a column the owner reads.
742
+ const handles = [];
743
+ // ---- FAIT ---------------------------------------------------------------------------------------
744
+ const outsideDone = outsideRollup.filter((r) => r.bucket === 'DONE' || r.bucket === 'DROPPED');
479
745
  const doneRows = [
480
- { scope: totalScope, progress: `${totals.done}/${totals.active} (${pctStr(totals.pct)})`, lastActions: `${totals.done} items faits; poursuivre les WP ouverts` },
481
- ...wpNodes.filter((n) => n.pct === 100).map((n) => ({ scope: wpName(n), progress: `${n.done}/${n.active} (100%)`, lastActions: 'WP clos; preuve/acceptance enregistrée' })),
746
+ {
747
+ scope: totalScope,
748
+ progress: `${totals.done}/${totals.active} (${pctStr(totals.pct)})`,
749
+ lastActions: lastActionsCell(wpNodes
750
+ .flatMap((n) => n.leaves.filter((l) => l.bucket === 'DONE'))
751
+ .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
752
+ .map((l) => clean(l.title))),
753
+ },
754
+ // Criterion 25 — one row per READING unit: the WP on a long window, the sub-level only when the
755
+ // window is short or the owner asked. `recentDoneLeaves` already walks children, so a root's row
756
+ // carries its sub-levels' deliveries rather than losing them.
757
+ ...rowNodes
758
+ .filter((n) => n.done > 0)
759
+ .map((n) => ({
760
+ scope: wpName(n),
761
+ progress: `${n.done}/${n.active} (${pctStr(n.pct)})`,
762
+ lastActions: lastActionsCell(recentDoneLeaves(n).map((l) => clean(l.title))),
763
+ })),
764
+ ...(outsideDone.length > 0
765
+ ? [{
766
+ scope: 'hors WP',
767
+ progress: (() => {
768
+ const done = outsideRollup.filter((r) => r.bucket === 'DONE').length;
769
+ const active = outsideRollup.filter((r) => r.bucket !== 'DROPPED').length;
770
+ return `${done}/${active} (${pctStr(active === 0 ? 'n/a' : Math.round((done / active) * 100))})`;
771
+ })(),
772
+ lastActions: outsideDone
773
+ .map((r) => `${clean(r.title)}${r.bucket === 'DROPPED' ? ' (abandonné)' : ''}`)
774
+ .join(' · '),
775
+ }]
776
+ : []),
482
777
  ];
483
- const todoRows = wpNodes.filter((n) => n.pct !== 100).map((n) => {
484
- const open = openLeaves(n);
485
- const listed = open.map((l) => clean(l.title)).join(' / ');
486
- return { wp: wpName(n), progress: `${n.done}/${n.active} (${pctStr(n.pct)})`, todo: listed || 'aucun item ouvert direct' };
778
+ // ---- À-FAIRE ------------------------------------------------------------------------------------
779
+ const directivesByWpId = new Map();
780
+ for (const directive of directives) {
781
+ const wpId = directive.scope.wpId;
782
+ if (wpId === undefined)
783
+ continue;
784
+ const attached = directivesByWpId.get(wpId);
785
+ if (attached === undefined)
786
+ directivesByWpId.set(wpId, [directive]);
787
+ else
788
+ attached.push(directive);
789
+ }
790
+ const urgencyIndex = new Map(directives.map((d, i) => [d.id, i]));
791
+ /**
792
+ * Criterion 7 + 19 — `bloqué` names the ANSWER that unblocks (a D-number) when the gate is a dossier,
793
+ * and a short gate token otherwise. It renders `—` ONLY when no gate is recorded at all.
794
+ */
795
+ const blockedCell = (attached) => {
796
+ const refs = [];
797
+ for (const d of attached) {
798
+ const gate = d.gate;
799
+ if (gate === undefined)
800
+ continue;
801
+ if (gate.code !== 'decision-pending') {
802
+ refs.push(GATE_TOKEN[gate.code] ?? 'blocage');
803
+ continue;
804
+ }
805
+ const ref = gate.ref ?? '';
806
+ const number = decisionRef.get(ref);
807
+ // A blocker still open against a dossier that has ALREADY been settled is an anomaly the owner
808
+ // should see, not a number pointing at a row this report no longer carries (criterion 23).
809
+ refs.push(number ?? (ref !== '' && !isPending.has(ref) ? 'décision réglée' : 'décision'));
810
+ }
811
+ return refs.length === 0 ? NO_GATE : compactRefs(refs);
812
+ };
813
+ /**
814
+ * Criterion 20 — the gate CLASS, kept as a machine-only property. It is what the old `prochaine action`
815
+ * printed; it is legitimate as a starting point for the agent's investigation and illegitimate as a
816
+ * recommendation, so it is carried and never rendered.
817
+ */
818
+ const gateStepClass = (attached) => [...new Set(attached.filter((d) => d.mode !== 'human-decision').map((d) => directivePhrase(d)))].join(' / ');
819
+ /** Does this row wait on a dossier the owner can answer? Then it owes no next action (criterion 14). */
820
+ const gatedOnPendingDecision = (attached) => attached.length > 0 &&
821
+ attached.every((d) => d.mode === 'human-decision' && isPending.has(d.gate?.ref ?? ''));
822
+ const directiveIds = (attached) => attached.map((d) => d.id).join(',');
823
+ // Machine-only audit properties (NOT declared columns, so no renderer ever prints them): the precise
824
+ // gate phrase the short `bloqué` token compacts, kept so nothing is lost from the projection.
825
+ const gateDetail = (attached) => [...new Set(attached.map((d) => gatePhraseFr(d.gate)).filter((p) => p !== undefined))].join(' / ');
826
+ const wpTodoDrafts = rowNodes
827
+ .filter((n) => openLeaves(n).length > 0 || branchOf(n).some((b) => directivesByWpId.has(b.id)))
828
+ .map((n) => {
829
+ // Criterion 25 — a sub-level's directives merge UPWARD with its leaves; they are not dropped.
830
+ const attached = branchOf(n).flatMap((b) => directivesByWpId.get(b.id) ?? []);
831
+ // The item's recorded body is ALREADY in the log; surfacing it costs no investigation and is what
832
+ // makes a `non instruite` row still say something (or admit that the log says nothing).
833
+ const items = openLeaves(n).map((l) => ({
834
+ id: l.id,
835
+ title: clean(l.title),
836
+ ...(todoExcerpt(l.summary) !== undefined ? { excerpt: todoExcerpt(l.summary) } : {}),
837
+ }));
838
+ // A directive may target a DONE leaf with acceptance debt: name it here (with its own handle)
839
+ // instead of exiling it to a `cible action` column the owner never asked for.
840
+ for (const d of attached) {
841
+ if (d.target.kind === 'decision' || items.some((i) => i.id === d.target.id))
842
+ continue;
843
+ const debtLeaf = wpNodes.flatMap((node) => node.leaves).find((l) => l.id === d.target.id);
844
+ items.push({
845
+ id: d.target.id,
846
+ title: clean(d.target.title ?? d.target.id),
847
+ note: d.facts.bucket.toLowerCase(),
848
+ ...(todoExcerpt(debtLeaf?.summary) !== undefined ? { excerpt: todoExcerpt(debtLeaf?.summary) } : {}),
849
+ });
850
+ }
851
+ const gated = gatedOnPendingDecision(attached);
852
+ return {
853
+ wp: wpName(n),
854
+ progress: pctStr(n.pct),
855
+ items,
856
+ blocked: blockedCell(attached),
857
+ gatedOnDecision: gated,
858
+ ...(gated ? { fixedNextAction: NO_ACTION } : {}),
859
+ directiveIds: directiveIds(attached),
860
+ gateDetail: gateDetail(attached),
861
+ gateStep: gateStepClass(attached),
862
+ order: String(Math.min(...attached.map((d) => urgencyIndex.get(d.id) ?? 9999), 9999)).padStart(5, '0'),
863
+ };
487
864
  });
488
- // DÉCISIONS/ACTIONS rowsderived from the directives. Decisions first, then engagement/work directives.
489
- // This is deliberately exhaustive: a conductor table is the deterministic route to every open row.
490
- const actionRows = [];
491
- if (focusNeeded >= 2 || humanDecisions.length >= 4) {
492
- actionRows.push({ scope: '-', subject: 'décisions accumulées', recommendation: 'focus (lecture): instruire le dossier, puis enregistrer le choix avec track decision select' });
865
+ // Criterion 24a PENDING dossier with no stored options cannot be answered, so it is not offered as a
866
+ // choice in DÉCISIONS. It is real open work: it appears here, with what would make it answerable.
867
+ // A structured pending dossier needs no À-FAIRE row — DÉCISIONS is where the owner answers it.
868
+ const unscopedDirectives = directives.filter((d) => d.scope.wpId === undefined);
869
+ const toStructure = unscopedDirectives.filter((d) => {
870
+ const ref = d.gate?.ref ?? d.target.id;
871
+ return decisionRef.get(ref)?.startsWith('Q') === true;
872
+ });
873
+ const outsideOpen = outsideRollup.filter((r) => (r.bucket === 'TO-DO' || r.bucket === 'AWAITED') && !unscopedDirectives.some((d) => d.target.id === r.id));
874
+ const horsWpDrafts = [];
875
+ if (toStructure.length > 0) {
876
+ horsWpDrafts.push({
877
+ wp: 'hors WP · dossiers à structurer',
878
+ progress: 'n/a',
879
+ // A dossier with no stored options says nothing by its title alone. Its prose context is recorded:
880
+ // show it as an EXCERPT — never as options, which is what `unstructured` forbids (criterion 16).
881
+ items: toStructure.map((d) => {
882
+ const ref = d.gate?.ref ?? d.target.id;
883
+ const excerpt = todoExcerpt(decisions.find((row) => row.id === ref)?.contextExcerpt);
884
+ return {
885
+ id: d.target.id,
886
+ title: clean(d.target.title ?? d.target.id),
887
+ ...(excerpt !== undefined ? { excerpt } : {}),
888
+ };
889
+ }),
890
+ // Criterion 19 — a gate IS recorded, so this is never `—`; and it names the actual blockage rather
891
+ // than pointing back at the row's own dossiers.
892
+ blocked: 'options non enregistrées',
893
+ fixedNextAction: NEXT_ACTION_STRUCTURE_DOSSIER,
894
+ gatedOnDecision: false,
895
+ directiveIds: directiveIds(toStructure),
896
+ gateDetail: gateDetail(toStructure),
897
+ gateStep: gateStepClass(toStructure),
898
+ order: String(Math.min(...toStructure.map((d) => urgencyIndex.get(d.id) ?? 9999))).padStart(5, '0'),
899
+ });
900
+ }
901
+ if (outsideOpen.length > 0) {
902
+ horsWpDrafts.push({
903
+ wp: 'hors WP · items',
904
+ progress: 'n/a',
905
+ items: outsideOpen.map((r) => ({
906
+ id: r.id,
907
+ title: clean(r.title),
908
+ ...(todoExcerpt(r.detail.summary) !== undefined ? { excerpt: todoExcerpt(r.detail.summary) } : {}),
909
+ })),
910
+ blocked: NO_GATE,
911
+ gatedOnDecision: false,
912
+ directiveIds: '',
913
+ gateDetail: '',
914
+ gateStep: '',
915
+ order: '09998',
916
+ });
917
+ }
918
+ const orderedDrafts = [...wpTodoDrafts, ...horsWpDrafts].sort((a, b) => a.order === b.order ? a.wp.localeCompare(b.wp) : a.order.localeCompare(b.order));
919
+ // Handles are assigned HERE, once the order is final: `[row.item]`, both 1-based (criterion 10b/10c).
920
+ // `prochaine action` is decided here too, because whether a row is FOCUS depends on that same order
921
+ // (criterion 20: the per-row investigation is bounded to the five rows the ordering line names).
922
+ const orderedTodo = orderedDrafts.map((draft, rowIndex) => {
923
+ const cells = draft.items.map((item, itemIndex) => {
924
+ const handle = `${rowIndex + 1}.${itemIndex + 1}`;
925
+ const wpLabel = draft.wp.split(' · ')[0];
926
+ handles.push({
927
+ handle, kind: 'item', id: item.id, title: item.title,
928
+ ...(wpLabel === undefined ? {} : { wpLabel }),
929
+ });
930
+ // Criterion 27 — one idea per line: the item on its line, and its recorded excerpt as a
931
+ // SUBORDINATE clause on its own, never a paragraph appended to the title.
932
+ const note = item.note === undefined ? '' : ` (${item.note})`;
933
+ const excerpt = item.excerpt === undefined ? '' : `\n↳ extrait : ${item.excerpt}`;
934
+ return `[${handle}] ${item.title}${note}${excerpt}`;
935
+ });
936
+ const nextAction = draft.fixedNextAction ??
937
+ (draft.items.length === 0
938
+ ? NO_ACTION
939
+ : rowIndex < FOCUS_ROWS
940
+ ? NEXT_ACTION_TO_INSTRUCT
941
+ : NEXT_ACTION_NOT_INSTRUCTED);
942
+ return {
943
+ wp: draft.wp,
944
+ progress: draft.progress,
945
+ todo: cells.join('\n'),
946
+ blocked: draft.blocked,
947
+ nextAction,
948
+ directiveIds: draft.directiveIds,
949
+ gateDetail: draft.gateDetail,
950
+ gateStep: draft.gateStep,
951
+ focus: rowIndex < FOCUS_ROWS ? 'true' : 'false',
952
+ };
953
+ });
954
+ for (const d of [...structuredPending, ...legacyPending]) {
955
+ handles.push({ handle: decisionRef.get(d.id), kind: 'decision', id: d.id, title: clean(d.title) });
493
956
  }
494
- for (const d of humanDecisions) {
495
- actionRows.push({ scope: directiveScopeLabel(d), subject: clean(d.target.title ?? d.target.id), recommendation: directivePhrase(d) });
957
+ const todoRows = orderedTodo.length > 0
958
+ ? orderedTodo
959
+ : [{ wp: '—', progress: 'n/a', todo: 'aucun WP ouvert', blocked: NO_GATE, nextAction: NO_ACTION, directiveIds: '' }];
960
+ // ---- DÉCISIONS ----------------------------------------------------------------------------------
961
+ // Criterion 23 — pending, answerable dossiers ONLY. Nothing else.
962
+ const decisionRows = [];
963
+ for (const d of structuredPending) {
964
+ const ref = decisionRef.get(d.id);
965
+ const options = d.options ?? [];
966
+ const letterOf = new Map(options.map((option, i) => [option.id, optionLetter(i)]));
967
+ // Criterion 16 — the recommendation sits on the LINE OF ITS OWN OPTION, and an unstructured dossier
968
+ // carries no letter at all rather than being dressed up as an owner choice.
969
+ const altLines = [];
970
+ const precoLines = [];
971
+ const recommended = d.recommendation === undefined ? undefined : letterOf.get(d.recommendation.optionId);
972
+ const selected = d.selectedOptionId === undefined ? undefined : letterOf.get(d.selectedOptionId);
973
+ options.forEach((option, i) => {
974
+ const letter = optionLetter(i);
975
+ const wrapped = wrapCell(`${letter} ${clean(option.title)} — ${clean(option.summary)}`, DECISION_CAPS[2]);
976
+ const marks = [];
977
+ if (letter === recommended)
978
+ marks.push(letter);
979
+ if (letter === selected)
980
+ marks.push('retenu');
981
+ altLines.push(...wrapped);
982
+ precoLines.push(marks.join(' '), ...Array(wrapped.length - 1).fill(''));
983
+ });
984
+ const alternatives = options.length > 0 ? altLines.join('\n') : 'non enregistrées';
985
+ let preco = precoLines.join('\n');
986
+ if (preco.trim() === '')
987
+ preco = '—';
988
+ decisionRows.push({ n: ref, subject: shortDecisionSubject(d.title), alternatives, preco });
496
989
  }
497
- for (const d of displayDirectives.filter((x) => x.mode !== 'human-decision')) {
498
- actionRows.push({ scope: directiveScopeLabel(d), subject: clean(d.target.title ?? d.target.id), recommendation: directivePhrase(d) });
990
+ if (decisionRows.length === 0) {
991
+ decisionRows.push({
992
+ n: '—',
993
+ subject: 'aucun dossier en attente que tu puisses trancher maintenant',
994
+ alternatives: 'non enregistrées',
995
+ preco: '—',
996
+ });
499
997
  }
500
- const outsideRows = outsideRollup.map((row) => ({
501
- scope: row.wpId === undefined ? 'sans WP' : `intermédiaire · ${row.wpLabel ?? '-'}`,
502
- progress: row.bucket,
503
- item: clean(row.title),
504
- }));
998
+ // ---- RECOMMANDATION ------------------------------------------------------------------------------
999
+ const startable = orderedTodo.filter((row) => row['nextAction'] !== NO_ACTION && !/(^|[^A-Z])D\d/u.test(row['blocked'] ?? ''));
1000
+ // Word-boundary match: `includes('D1')` also matches `D10`, which would credit the wrong dossier.
1001
+ const unlockedBy = (ref) => orderedTodo
1002
+ .filter((row) => new RegExp(`(^|[^0-9A-Z])${ref}([^0-9]|$)`, 'u').test(row['blocked'] ?? ''))
1003
+ .map((row) => (row['wp'] ?? '').split(' · ')[0]);
1004
+ const recommendationLines = [];
1005
+ recommendationLines.push(startable.length === 0
1006
+ ? 'Sans décision : aucune lane exécutable sans réponse n’est attestée dans le journal.'
1007
+ : `Sans décision : ${startable
1008
+ .slice(0, 3)
1009
+ .map((row) => (row['wp'] ?? '').split(' · ')[0])
1010
+ .join(', ')} peuvent démarrer — le geste concret reste à instruire par ligne.`);
1011
+ if (structuredPending.length === 0) {
1012
+ recommendationLines.push('Aucun D# disponible : aucun dossier structuré sélectionnable dans le journal.');
1013
+ }
1014
+ else {
1015
+ for (const d of structuredPending) {
1016
+ const ref = decisionRef.get(d.id);
1017
+ const letter = d.recommendation === undefined
1018
+ ? undefined
1019
+ : optionLetter((d.options ?? []).findIndex((o) => o.id === d.recommendation.optionId));
1020
+ const targets = unlockedBy(ref);
1021
+ recommendationLines.push(`${ref}${letter === undefined ? '' : ` ${letter}`} → débloque ${targets.length > 0 ? [...new Set(targets)].join(', ') : 'le dossier lui-même'}.`);
1022
+ }
1023
+ }
1024
+ const replyLine = structuredPending.length === 0
1025
+ ? 'Réponds « vas y » pour lancer les lanes sans décision.'
1026
+ : `Réponds « vas y » (les lanes sans décision) ou « ${structuredPending
1027
+ .map((d) => {
1028
+ const ref = decisionRef.get(d.id);
1029
+ const letter = d.recommendation === undefined
1030
+ ? 'A'
1031
+ : optionLetter((d.options ?? []).findIndex((o) => o.id === d.recommendation.optionId));
1032
+ return `${ref} ${letter}`;
1033
+ })
1034
+ .join(' · ')} » (tout débloquer).`;
1035
+ recommendationLines.push(replyLine);
1036
+ // Criteria 17/24 — compression is allowed, silence is not, and every omission NAMES ITS REASON.
1037
+ // Criterion 18 is what keeps this safe: a WP carrying open work, and every dossier the owner can still
1038
+ // answer, are in the rendered lists above and can never fall here.
1039
+ const empty = (n) => n.done === 0 && openLeaves(n).length === 0 && !directivesByWpId.has(n.id);
1040
+ // Criterion 25 — a sub-level that CARRIES something is restituted inside its parent, so it is neither
1041
+ // rendered as a row nor omitted: it is aggregated, and the header says how many.
1042
+ const aggregated = subNodes.filter((n) => !empty(n)).map(wpName);
1043
+ const omitted = [
1044
+ ...wpNodes
1045
+ .filter(empty)
1046
+ .map((n) => ({ label: wpName(n), reason: 'WP sans item ouvert, sans blocage et sans livraison' })),
1047
+ ...settledDecisions.map((d) => ({
1048
+ label: shortDecisionSubject(d.title),
1049
+ reason: 'décision déjà tranchée (visible dans bloqué ou FAIT, plus rien à y répondre)',
1050
+ })),
1051
+ ];
1052
+ // ---- coverage (criteria 17/18) --------------------------------------------------------------------
1053
+ // 17 — the report STATES both counts, so omission is a declared act rather than a silent one. Both
1054
+ // numbers count the SAME unit: rows of the deterministic projection. `rendered` is therefore always a
1055
+ // subset of `projected`, and `projected - rendered === omitted.length`.
1056
+ // 18 — the two classes that may never be omitted (a WP carrying open work, a pending dossier) are
1057
+ // structurally in the rendered lists above, whatever the compression ratio.
1058
+ // An unscoped directive always TARGETS a dossier already counted in `decisions`, so counting it again
1059
+ // would inflate the denominator against itself.
1060
+ const projectedRows = wpNodes.length + outsideRollup.length + decisions.length;
1061
+ const coverage = {
1062
+ projected: projectedRows,
1063
+ rendered: projectedRows - omitted.length,
1064
+ omitted,
1065
+ aggregated,
1066
+ };
1067
+ const header = {
1068
+ scope: totalScope,
1069
+ progress: `${totals.done}/${totals.active} (${pctStr(totals.pct)})`,
1070
+ ...(meta.baselineCommit !== undefined ? { baselineCommit: meta.baselineCommit.slice(0, 12) } : {}),
1071
+ // Criterion 21 — the window is measured in the log, so it is always stated, always with dates.
1072
+ period,
1073
+ sources: ['projection déterministe du journal (track report --wp --decisions)'],
1074
+ coverage,
1075
+ handleCommand: 'track report --resolve <handle>',
1076
+ };
505
1077
  return {
506
1078
  kind: 'wp-conductor-report',
507
1079
  locale: 'fr',
1080
+ header,
508
1081
  tables: [
509
- { id: 'done', title: 'FAIT', columns: [{ id: 'scope', label: 'scope' }, { id: 'progress', label: 'avancement' }, { id: 'lastActions', label: 'dernières actions' }], rows: doneRows },
510
- { id: 'todo', title: 'À-FAIRE', columns: [{ id: 'wp', label: 'WP' }, { id: 'progress', label: 'avancement' }, { id: 'todo', label: 'à faire' }], rows: todoRows.length > 0 ? todoRows : [{ wp: '-', progress: '-', todo: 'aucun WP ouvert' }] },
511
- ...(outsideRows.length > 0
512
- ? [{ id: 'outside-rollup', title: 'HORS ROLLUP', columns: [{ id: 'scope', label: 'rattachement' }, { id: 'progress', label: 'état' }, { id: 'item', label: 'item' }], rows: outsideRows }]
513
- : []),
514
- ...(structuredDecisions.length > 0 ? [{ id: 'decisions', title: 'DÉCISIONS', columns: [{ id: 'decision', label: 'dossier' }, { id: 'alternatives', label: 'alternatives enregistrées' }, { id: 'recommendation', label: 'recommandation / règlement' }], rows: structuredDecisions.map((d) => ({
515
- decision: `${d.id} ${clean(d.title)} (${d.outcome})`,
516
- alternatives: d.options.map((option) => `${option.id}: ${clean(option.title)} — ${clean(option.summary)}`).join(' / '),
517
- recommendation: `recommandée:${d.recommendation.optionId} — ${clean(d.recommendation.rationale)}${d.selectedOptionId !== undefined ? `; sélectionnée:${d.selectedOptionId}` : ''}`,
518
- })) }] : []),
519
- ...(legacyPending.length > 0 ? [{ id: 'prepare', title: 'À INSTRUIRE', columns: [{ id: 'decision', label: 'dossier legacy' }, { id: 'action', label: 'disposition sûre' }], rows: legacyPending.map((d) => ({ decision: `${d.id} — ${clean(d.title)}`, action: legacyRevisionAction(d) })) }] : []),
520
- ...(legacySettled.length > 0 ? [{ id: 'legacy-history', title: 'HISTORIQUE NON STRUCTURÉ', columns: [{ id: 'decision', label: 'dossier legacy' }, { id: 'record', label: 'constat' }], rows: legacySettled.map((d) => ({ decision: `${d.id} — ${clean(d.title)}`, record: legacyHistoryNote(d) })) }] : []),
521
- { id: 'decisions-actions', title: 'DÉCISIONS/ACTIONS', columns: [{ id: 'scope', label: 'scope/gate' }, { id: 'subject', label: 'sujet' }, { id: 'recommendation', label: 'préconisation' }], rows: actionRows.length > 0 ? actionRows : [{ scope: '-', subject: 'aucune action ouverte dans les WP actifs', recommendation: '-' }] },
1082
+ {
1083
+ id: 'done',
1084
+ title: 'FAIT',
1085
+ columns: [
1086
+ { id: 'scope', label: 'scope' },
1087
+ { id: 'progress', label: 'avancement' },
1088
+ { id: 'lastActions', label: 'dernières actions' },
1089
+ ],
1090
+ rows: doneRows,
1091
+ },
1092
+ {
1093
+ id: 'todo',
1094
+ title: 'À-FAIRE',
1095
+ columns: [
1096
+ { id: 'wp', label: 'WP' },
1097
+ { id: 'progress', label: 'av.' },
1098
+ { id: 'todo', label: 'à faire' },
1099
+ { id: 'blocked', label: 'bloqué' },
1100
+ { id: 'nextAction', label: 'prochaine action' },
1101
+ ],
1102
+ rows: todoRows,
1103
+ },
1104
+ {
1105
+ id: 'decisions',
1106
+ title: 'DÉCISIONS',
1107
+ render: 'drawn',
1108
+ columns: [
1109
+ { id: 'n', label: '#' },
1110
+ { id: 'subject', label: 'sujet' },
1111
+ { id: 'alternatives', label: 'alternatives' },
1112
+ { id: 'preco', label: 'préco' },
1113
+ ],
1114
+ rows: decisionRows,
1115
+ },
1116
+ {
1117
+ id: 'recommendation',
1118
+ title: 'RECOMMANDATION',
1119
+ render: 'prose',
1120
+ columns: [],
1121
+ rows: [],
1122
+ lines: recommendationLines,
1123
+ },
522
1124
  ],
523
- generalRecommendation,
1125
+ handles,
1126
+ coverage,
524
1127
  directives,
1128
+ directivesProjection: { kind: 'conductor-action-directives', order: 'canonical-urgency' },
525
1129
  dispatchQueue,
1130
+ dispatchQueueProjection: { kind: 'delegable-directive-ids', order: 'canonical-urgency', modes: ['subagent', 'local'] },
526
1131
  ...(keystone !== undefined ? { keystone } : {}),
527
1132
  };
528
1133
  }
1134
+ /**
1135
+ * Criterion 11 — a decision SUBJECT, not the stored title pasted verbatim. Deterministic and lossless of
1136
+ * meaning: it drops a leading enumeration counter (`1/6 — `, `x7 — `) that carries no question. Turning
1137
+ * the remainder into a short question is a synthesis act and belongs to the skill, not to this renderer:
1138
+ * inventing a shorter wording here would be fabrication.
1139
+ */
1140
+ export function shortDecisionSubject(storedTitle) {
1141
+ return clean(storedTitle).replace(/^(?:\d+\s*\/\s*\d+|x\d+|§\d+)\s*[—–-]\s*/u, '');
1142
+ }
1143
+ /**
1144
+ * Criteria 17/24 — both counts AND the reason for every omission, grouped so the line stays readable.
1145
+ * "Omitted" without a why is the silence the criterion exists to forbid.
1146
+ */
1147
+ export function coverageLine(coverage) {
1148
+ const merged = coverage.aggregated.length > 0
1149
+ ? ` · ${coverage.aggregated.length} sous-WP agrégés dans leur parent`
1150
+ : '';
1151
+ const head = `couverture : ${coverage.projected} lignes projetées · ${coverage.rendered} rendues${merged}`;
1152
+ if (coverage.omitted.length === 0)
1153
+ return `${head} · aucune omission`;
1154
+ const byReason = new Map();
1155
+ for (const omission of coverage.omitted)
1156
+ byReason.set(omission.reason, (byReason.get(omission.reason) ?? 0) + 1);
1157
+ const detail = [...byReason.entries()].map(([reason, count]) => `${count} ${reason}`).join(' · ');
1158
+ return `${head} · ${coverage.omitted.length} omise${coverage.omitted.length > 1 ? 's' : ''} : ${detail}`;
1159
+ }
1160
+ /** The À-FAIRE ordering rule, printed so the owner knows why the rows are in this order (criterion 6). */
1161
+ const TODO_ORDER_NOTE = 'ordre = priorité ; les cinq premiers sont le focus';
1162
+ function headerLines(view, format) {
1163
+ const h = view.header;
1164
+ const em = (s) => (format === 'md' ? `*${s}*` : s);
1165
+ const lines = [
1166
+ format === 'md'
1167
+ ? `# TRACK REPORT — ${h.scope} · ${h.progress}`
1168
+ : `TRACK REPORT — ${h.scope} · ${h.progress}`,
1169
+ em(h.period.label),
1170
+ em(`baseline d’acceptance : ${h.baselineCommit ?? 'non résolue'}`),
1171
+ em(coverageLine(h.coverage)),
1172
+ em(`sources : ${h.sources.join(' ; ')}`),
1173
+ '',
1174
+ ];
1175
+ return lines;
1176
+ }
1177
+ /**
1178
+ * Criteria 10b/10c — the machine's half of the page. It is NOT a fifth section and NOT a table the owner
1179
+ * reads: it is the block that makes a handle actionable, and the place the ULID is allowed to live. It
1180
+ * states plainly that handles are positional and per-report, so a reply quoting `[3.2]` without the report
1181
+ * it came from is not actionable.
1182
+ */
1183
+ export const RESOLUTION_TITLE = 'RÉSOLUTION DES HANDLES (bloc machine — pas une table à lire)';
1184
+ export function resolutionLines(view) {
1185
+ const lines = [
1186
+ RESOLUTION_TITLE,
1187
+ 'handles positionnels, valables pour CE rapport uniquement : une réponse qui cite un handle sans son rapport n’est pas actionnable.',
1188
+ `commande : ${view.header.handleCommand}`,
1189
+ ];
1190
+ for (const h of view.handles)
1191
+ lines.push(`${h.handle}\t${h.id}\t${h.title}`);
1192
+ if (view.handles.length === 0)
1193
+ lines.push('(aucun handle émis)');
1194
+ return lines;
1195
+ }
529
1196
  function renderReportView(view, format) {
530
1197
  if (format === 'json')
531
1198
  return JSON.stringify(view, null, 2) + '\n';
532
1199
  // User-originated cell content (titles) is escaped per-format: `md` escapes markdown metacharacters so a
533
1200
  // crafted item title cannot inject formatting (parity with the legacy `formatReport`/`title` path); `text`
534
- // is clean. The view model itself stays RAW (escaping is a render-only concern).
535
- const esc = (s) => title(s, format);
1201
+ // is clean. The view model itself stays RAW (escaping is a render-only concern). `displayCell` keeps the
1202
+ // machine-generated `[n.m]` handle out of that escaped span so the three formats agree on it.
1203
+ const esc = (s) => displayCell(s, format);
536
1204
  const h = (label) => (format === 'md' ? `## ${label}` : label);
537
- const lines = [];
1205
+ const lines = headerLines(view, format);
538
1206
  for (const section of view.tables) {
539
1207
  lines.push(h(section.title));
540
- lines.push(...table(section.columns.map((c) => c.label), section.rows.map((row) => section.columns.map((c) => esc(row[c.id] ?? '')))));
1208
+ if (section.render === 'prose') {
1209
+ // Renderer-authored French sentences interpolating only derived labels, handles and D-numbers —
1210
+ // never a raw user title, so there is nothing to escape and nothing to inject.
1211
+ lines.push(...(section.lines ?? []));
1212
+ }
1213
+ else if (section.render === 'drawn') {
1214
+ // The box-drawn table is emitted verbatim; `md` fences it so the alignment survives.
1215
+ const drawn = drawTable(section.columns.map((c) => c.label), section.rows.map((row) => section.columns.map((c) => clean0(row[c.id] ?? ''))), DECISION_CAPS, [false, false, false, true]);
1216
+ const fence = fenceFor(drawn);
1217
+ if (format === 'md')
1218
+ lines.push(fence);
1219
+ lines.push(...drawn);
1220
+ if (format === 'md')
1221
+ lines.push(fence);
1222
+ }
1223
+ else {
1224
+ if (section.id === 'todo')
1225
+ lines.push(format === 'md' ? `*${TODO_ORDER_NOTE}*` : TODO_ORDER_NOTE);
1226
+ lines.push(...table(section.columns.map((c) => c.label), section.rows.map((row) => section.columns.map((c) => esc(row[c.id] ?? '')))));
1227
+ }
541
1228
  lines.push('');
542
1229
  }
543
- lines.push(h('RECOMMANDATION'));
544
- lines.push(esc(view.generalRecommendation));
1230
+ const resolution = resolutionLines(view);
1231
+ const resolutionFence = fenceFor(resolution);
1232
+ if (format === 'md')
1233
+ lines.push(resolutionFence);
1234
+ lines.push(...resolution);
1235
+ if (format === 'md')
1236
+ lines.push(resolutionFence);
545
1237
  return lines.join('\n').trimEnd() + '\n';
546
1238
  }
547
- export function formatWpConductor(tree, format, decisions = [], outsideRollup = [], totalScope = 'global') {
548
- return renderReportView(buildWpConductorView(tree, decisions, outsideRollup, totalScope), format);
1239
+ /**
1240
+ * A fence long enough to contain `lines` (CommonMark: an opening fence must be longer than any backtick
1241
+ * run inside it). Without this, a single item title carrying ``` would close the fence early and let the
1242
+ * rest of a MACHINE block — the drawn table, the handle→id map — render as markdown.
1243
+ */
1244
+ function fenceFor(lines) {
1245
+ let longest = 0;
1246
+ for (const line of lines) {
1247
+ for (const run of line.match(/`+/gu) ?? [])
1248
+ longest = Math.max(longest, run.length);
1249
+ }
1250
+ return '`'.repeat(Math.max(3, longest + 1));
1251
+ }
1252
+ /** Like `clean`, but PRESERVES the explicit `\n` line breaks a drawn cell uses to align its options. */
1253
+ function clean0(s) {
1254
+ return s.split('\n').map((line) => cell(line)).join('\n');
1255
+ }
1256
+ export function formatWpConductor(tree, format, decisions = [], outsideRollup = [], totalScope = 'global', meta = {}) {
1257
+ return renderReportView(buildWpConductorView(tree, decisions, outsideRollup, totalScope, meta), format);
549
1258
  }
550
1259
  /** Clean + hard-truncate a line to `width` with a trailing ellipsis (never a silent cut mid-report). */
551
1260
  function truncateLine(s, width) {