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