@sentropic/track 0.86.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.
Files changed (79) hide show
  1. package/dist/cli/bin.js +14 -1
  2. package/dist/cli/bin.js.map +1 -1
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +206 -66
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/install-skills.js +10 -10
  7. package/dist/events/types.d.ts +1 -1
  8. package/dist/events/types.d.ts.map +1 -1
  9. package/dist/events/types.js +3 -0
  10. package/dist/events/types.js.map +1 -1
  11. package/dist/focus-vendor/cli/index.d.ts +1 -1
  12. package/dist/ingest/contract.d.ts +2 -2
  13. package/dist/ingest/contract.d.ts.map +1 -1
  14. package/dist/ingest/contract.js +13 -2
  15. package/dist/ingest/contract.js.map +1 -1
  16. package/dist/ingest/focus-l4.d.ts +1 -1
  17. package/dist/ingest/focus-l4.d.ts.map +1 -1
  18. package/dist/ingest/focus-l4.js +10 -2
  19. package/dist/ingest/focus-l4.js.map +1 -1
  20. package/dist/ingest/ingest.d.ts.map +1 -1
  21. package/dist/ingest/ingest.js +8 -0
  22. package/dist/ingest/ingest.js.map +1 -1
  23. package/dist/ingest/map.d.ts.map +1 -1
  24. package/dist/ingest/map.js +3 -0
  25. package/dist/ingest/map.js.map +1 -1
  26. package/dist/mcp/server.d.ts +2 -2
  27. package/dist/mcp/server.js +2 -2
  28. package/dist/mcp/server.js.map +1 -1
  29. package/dist/model/decision.d.ts +11 -0
  30. package/dist/model/decision.d.ts.map +1 -1
  31. package/dist/model/decision.js +66 -0
  32. package/dist/model/decision.js.map +1 -1
  33. package/dist/read/commands.d.ts +8 -14
  34. package/dist/read/commands.d.ts.map +1 -1
  35. package/dist/read/commands.js +52 -14
  36. package/dist/read/commands.js.map +1 -1
  37. package/dist/read/contract.d.ts +29 -6
  38. package/dist/read/contract.d.ts.map +1 -1
  39. package/dist/read/contract.js +46 -8
  40. package/dist/read/contract.js.map +1 -1
  41. package/dist/report/build.d.ts +26 -1
  42. package/dist/report/build.d.ts.map +1 -1
  43. package/dist/report/build.js +42 -45
  44. package/dist/report/build.js.map +1 -1
  45. package/dist/report/directive.d.ts.map +1 -1
  46. package/dist/report/directive.js +6 -4
  47. package/dist/report/directive.js.map +1 -1
  48. package/dist/report/format.d.ts +185 -11
  49. package/dist/report/format.d.ts.map +1 -1
  50. package/dist/report/format.js +924 -120
  51. package/dist/report/format.js.map +1 -1
  52. package/dist/report/html.d.ts +3 -3
  53. package/dist/report/html.d.ts.map +1 -1
  54. package/dist/report/html.js +41 -44
  55. package/dist/report/html.js.map +1 -1
  56. package/dist/report/index.d.ts +1 -1
  57. package/dist/report/index.d.ts.map +1 -1
  58. package/dist/report/index.js +1 -1
  59. package/dist/report/index.js.map +1 -1
  60. package/dist/report/rollup.d.ts +14 -0
  61. package/dist/report/rollup.d.ts.map +1 -1
  62. package/dist/report/rollup.js +32 -0
  63. package/dist/report/rollup.js.map +1 -1
  64. package/dist/report/snapshot.d.ts +22 -2
  65. package/dist/report/snapshot.d.ts.map +1 -1
  66. package/dist/report/snapshot.js +16 -3
  67. package/dist/report/snapshot.js.map +1 -1
  68. package/dist/report/status-by-level.d.ts +1 -1
  69. package/dist/report/status-by-level.d.ts.map +1 -1
  70. package/dist/report/status-by-level.js +13 -30
  71. package/dist/report/status-by-level.js.map +1 -1
  72. package/dist/state/fold.js +10 -0
  73. package/dist/state/fold.js.map +1 -1
  74. package/dist/track.d.ts +6 -4
  75. package/dist/track.d.ts.map +1 -1
  76. package/dist/track.js +92 -38
  77. package/dist/track.js.map +1 -1
  78. package/package.json +2 -2
  79. package/skills/track-operation/SKILL.md +62 -88
@@ -3,14 +3,14 @@ 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([
10
10
  BACKSLASH, '`', '*', '_', '[', ']', '{', '}', '(', ')', '#', '+', '|', '<', '>', '!', '~', '-',
11
11
  ]);
12
12
  /** Collapse control characters (newlines, tabs, line separators) to single spaces. */
13
- function clean(s) {
13
+ export function cleanDisplayText(s) {
14
14
  let out = '';
15
15
  let prevSpace = false;
16
16
  for (const ch of s) {
@@ -29,16 +29,59 @@ function clean(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
- function title(s, format) {
34
- const t = clean(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
+ }
79
+ function clean(s) {
80
+ return cleanDisplayText(s);
81
+ }
82
+ function title(s, format) {
83
+ return displayText(s, format);
84
+ }
42
85
  function heading(label, count, format) {
43
86
  return format === 'md' ? `## ${label} (${count})` : `${label} (${count})`;
44
87
  }
@@ -50,6 +93,23 @@ function rowLine(r, format) {
50
93
  ? `- **${title(r.title, format)}** — ${meta(r)}`
51
94
  : ` - ${title(r.title, format)} [${meta(r)}]`;
52
95
  }
96
+ /**
97
+ * Classify folded decision rows once for every human renderer. Legacy events remain
98
+ * readable, but only a dossier that satisfies the native validator is actionable.
99
+ */
100
+ function classifyDecisions(decisions) {
101
+ return {
102
+ structured: decisions.filter((decision) => decision.structured === true),
103
+ legacyPending: decisions.filter((decision) => decision.structured !== true && decision.outcome === 'pending'),
104
+ legacySettled: decisions.filter((decision) => decision.structured !== true && decision.outcome !== 'pending'),
105
+ };
106
+ }
107
+ function legacyRevisionAction(decision) {
108
+ return `alternatives et recommandation non enregistrées; réviser avec track decision dossier ${decision.id}`;
109
+ }
110
+ function legacyHistoryNote(decision) {
111
+ return `outcome historique:${decision.outcome}; aucune option sélectionnée n'est attestée`;
112
+ }
53
113
  export function formatReport(report, format) {
54
114
  if (format === 'json')
55
115
  return JSON.stringify(report, null, 2);
@@ -62,8 +122,9 @@ export function formatReport(report, format) {
62
122
  lines.push('');
63
123
  }
64
124
  if (report.decisions !== undefined) {
65
- lines.push(heading('DECISIONS', report.decisions.length, format));
66
- for (const d of report.decisions) {
125
+ const { structured, legacyPending, legacySettled } = classifyDecisions(report.decisions);
126
+ lines.push(heading('DECISIONS', structured.length, format));
127
+ for (const d of structured) {
67
128
  const t = title(d.title, format);
68
129
  // D6-B (WP5): surface the sponsor (= `accountable`, D6 resolved) when present. Additive — a
69
130
  // decision without a sponsor renders exactly as before (no trailing segment).
@@ -71,11 +132,30 @@ export function formatReport(report, format) {
71
132
  lines.push(format === 'md'
72
133
  ? `- **${t}** — ${d.decisionKind} · ${d.realization} · outcome:${d.outcome}${sponsor}`
73
134
  : ` - ${t} [${d.decisionKind}, ${d.realization}, outcome:${d.outcome}${d.accountable !== undefined ? `, sponsor:${d.accountable}` : ''}]`);
135
+ const alternatives = d.options?.map((option) => `${option.id}: ${title(option.title, format)} — ${title(option.summary, format)}`).join(' / ') ?? '';
136
+ const recommendation = d.recommendation === undefined
137
+ ? ''
138
+ : `recommandation:${d.recommendation.optionId} — ${title(d.recommendation.rationale, format)}`;
139
+ lines.push(format === 'md' ? ` - alternatives: ${alternatives}` : ` alternatives: ${alternatives}`);
140
+ lines.push(format === 'md' ? ` - ${recommendation}` : ` ${recommendation}`);
141
+ }
142
+ if (legacyPending.length > 0) {
143
+ lines.push(heading('À INSTRUIRE (legacy)', legacyPending.length, format));
144
+ for (const d of legacyPending) {
145
+ const t = title(d.title, format);
146
+ lines.push(format === 'md' ? `- **${t}** — ${legacyRevisionAction(d)}` : ` - ${t} [${legacyRevisionAction(d)}]`);
147
+ }
148
+ }
149
+ if (legacySettled.length > 0) {
150
+ lines.push(heading('HISTORIQUE NON STRUCTURÉ', legacySettled.length, format));
151
+ for (const d of legacySettled) {
152
+ const t = title(d.title, format);
153
+ lines.push(format === 'md' ? `- **${t}** — ${legacyHistoryNote(d)}` : ` - ${t} [${legacyHistoryNote(d)}]`);
154
+ }
74
155
  }
75
156
  }
76
157
  return lines.join('\n').trimEnd() + '\n';
77
158
  }
78
- const topRows = (rows, max) => rows.slice(0, max);
79
159
  function actionDisposition(r) {
80
160
  if (r.engagementRef !== undefined)
81
161
  return 'relancer engagement/subagent';
@@ -85,40 +165,25 @@ function actionDisposition(r) {
85
165
  return 'terminer ou expliciter blocage';
86
166
  return 'exécuter prochain incrément';
87
167
  }
88
- function decisionDisposition(d) {
89
- if (decisionNeedsFocus(d)) {
90
- return `focus décision HTML conseillé: track focus ${d.id} --format html`;
91
- }
92
- if ((d.openQuestionCount ?? 0) > 0)
93
- return 'répondre aux questions ouvertes puis trancher';
94
- if (d.decisionKind === 'commitment')
95
- return 'trancher go/no-go et enregistrer outcome';
96
- return 'choisir l’orientation recommandée puis enregistrer outcome';
97
- }
98
168
  function cell(s) {
99
169
  return clean(s).replaceAll('|', '¦');
100
170
  }
101
- function wrapCell(s, width, maxLines = 3) {
171
+ function wrapCell(s, width) {
102
172
  const c = cell(s).trim();
103
173
  if (c.length === 0)
104
174
  return [''];
105
175
  const words = c.split(/\s+/);
106
176
  const lines = [];
107
177
  let line = '';
108
- let consumed = 0;
109
- for (const word of words) {
110
- if (lines.length >= maxLines)
111
- break;
112
- if (word.length > width) {
178
+ for (const originalWord of words) {
179
+ let word = originalWord;
180
+ while (word.length > width) {
113
181
  if (line.length > 0) {
114
182
  lines.push(line);
115
183
  line = '';
116
- if (lines.length >= maxLines)
117
- break;
118
184
  }
119
185
  lines.push(word.slice(0, width));
120
- consumed++;
121
- continue;
186
+ word = word.slice(width);
122
187
  }
123
188
  const next = line.length === 0 ? word : `${line} ${word}`;
124
189
  if (next.length <= width)
@@ -126,36 +191,46 @@ function wrapCell(s, width, maxLines = 3) {
126
191
  else {
127
192
  lines.push(line);
128
193
  line = word;
129
- if (lines.length >= maxLines)
130
- break;
131
194
  }
132
- consumed++;
133
195
  }
134
- if (line.length > 0 && lines.length < maxLines)
196
+ if (line.length > 0)
135
197
  lines.push(line);
136
- if (consumed < words.length && lines.length > 0)
137
- lines[lines.length - 1] = `${lines[lines.length - 1]} ↳ détail --flat`;
138
198
  return lines;
139
199
  }
140
- 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) {
141
223
  // Terminal-first padded table: aligned columns, bounded width, MULTI-LINE cells.
142
224
  // No ellipsis: long content wraps inside the column so the report stays readable and complete enough.
143
- const caps = headers.map((h) => {
144
- const k = h.toLowerCase();
145
- if (k.includes('sujet') || k.includes('items') || k.includes('à faire'))
146
- return 72;
147
- if (k.includes('préconisation') || k.includes('dernières actions'))
148
- return 64;
149
- if (k.includes('complexité') || k.includes('notes') || k.includes('dropped'))
150
- return 38;
151
- if (k.includes('scope') || k.includes('wp'))
152
- return 42;
153
- return 24;
154
- });
225
+ const caps = headers.map((h, i) => capOverrides?.[i] ?? defaultCap(h));
155
226
  const head = headers.map((h, i) => cell(h).slice(0, caps[i]));
156
- 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])))));
157
230
  const widths = head.map((h, i) => Math.min(caps[i], Math.max(h.length, ...wrappedRows.flatMap((r) => r[i]).map((v) => v.length))));
158
- const renderLine = (row) => row.map((v, i) => v.padEnd(widths[i])).join(' ');
231
+ // Padding aligns interior columns; remove only terminal padding so reports and committed fixtures do not
232
+ // carry invisible trailing whitespace.
233
+ const renderLine = (row) => row.map((v, i) => v.padEnd(widths[i])).join(' ').trimEnd();
159
234
  const out = [renderLine(head), renderLine(widths.map((w) => '─'.repeat(w)))];
160
235
  for (const row of wrappedRows) {
161
236
  const height = Math.max(...row.map((cellLines) => cellLines.length));
@@ -167,9 +242,36 @@ function table(headers, rows) {
167
242
  out.pop();
168
243
  return out;
169
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
+ }
170
272
  /**
171
273
  * Directive fallback for repos that have no WP containers yet. This is intentionally NOT the exhaustive
172
- * 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
173
275
  * the full bucket listing.
174
276
  */
175
277
  export function formatActionReport(report, format) {
@@ -181,23 +283,18 @@ export function formatActionReport(report, format) {
181
283
  const todo = report.buckets['TO-DO'];
182
284
  const done = report.buckets.DONE;
183
285
  const dropped = report.buckets.DROPPED;
184
- const pendingDecisions = report.decisions?.filter((d) => d.outcome === 'pending') ?? [];
286
+ const allDecisions = report.decisions ?? [];
287
+ const { structured, legacyPending, legacySettled } = classifyDecisions(allDecisions);
288
+ const pendingDecisions = structured.filter((d) => d.outcome === 'pending');
185
289
  lines.push(h('SYNTHÈSE'));
186
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)]]));
187
291
  lines.push('');
188
- lines.push(h('DÉCISIONS/ACTIONS'));
189
- const candidates = [...awaited, ...todo].slice(0, 10);
292
+ lines.push(h('ACTIONS DÉRIVÉES'));
293
+ const candidates = [...awaited, ...todo];
190
294
  const actionRows = [];
191
295
  const focusCount = pendingDecisions.filter(decisionNeedsFocus).length;
192
296
  if (focusCount >= 2 || pendingDecisions.length >= 4) {
193
- actionRows.push(['focus', 'décisions accumulées', 'focus (humain+MCP): lancer focus HTML local; retour outcome via vue interactive/MCP']);
194
- }
195
- for (const d of pendingDecisions.slice(0, 8)) {
196
- actionRows.push([
197
- d.decisionKind,
198
- title(d.title, format),
199
- `décision (${d.accountable ?? 'owner'}): ${decisionDisposition(d)}`,
200
- ]);
297
+ actionRows.push(['focus', 'décisions accumulées', 'focus (humain): lancer focus HTML local; régler toute option choisie avec track decision select']);
201
298
  }
202
299
  for (const r of candidates) {
203
300
  actionRows.push([
@@ -206,14 +303,31 @@ export function formatActionReport(report, format) {
206
303
  `action (${r.engagementRef !== undefined ? 'h2a/subagent' : 'local/subagent'}): ${actionDisposition(r)}`,
207
304
  ]);
208
305
  }
209
- 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', '-']]));
210
307
  lines.push('');
308
+ if (structured.length > 0) {
309
+ lines.push(h('DÉCISIONS'));
310
+ lines.push(...table(['dossier', 'alternatives enregistrées', 'recommandation / règlement'], structured.map((d) => [
311
+ `${d.id} — ${title(d.title, format)} (${d.outcome})`,
312
+ d.options?.map((option) => `${option.id}: ${title(option.title, format)} — ${title(option.summary, format)}`).join(' / ') ?? '-',
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>` : ''}`,
314
+ ])));
315
+ lines.push('');
316
+ }
317
+ if (legacyPending.length > 0) {
318
+ lines.push(h('À INSTRUIRE'));
319
+ lines.push(...table(['dossier legacy', 'disposition sûre'], legacyPending.map((d) => [`${d.id} — ${title(d.title, format)}`, legacyRevisionAction(d)])));
320
+ lines.push('');
321
+ }
322
+ if (legacySettled.length > 0) {
323
+ lines.push(h('HISTORIQUE NON STRUCTURÉ'));
324
+ lines.push(...table(['dossier legacy', 'constat'], legacySettled.map((d) => [`${d.id} — ${title(d.title, format)}`, legacyHistoryNote(d)])));
325
+ lines.push('');
326
+ }
211
327
  lines.push(h('FAIT RÉCENT / REPÈRES'));
212
- const doneRows = topRows(done, 5).map((r) => ['done', title(r.title, format), r.acceptance]);
213
- if (done.length > 5)
214
- doneRows.push(['info', `${done.length - 5} autres done; utiliser --flat pour le détail complet`, '']);
215
- if (dropped.length > 0)
216
- doneRows.push(['dropped', `${dropped.length}; utiliser --flat pour audit`, '']);
328
+ const doneRows = done.map((r) => ['done', title(r.title, format), r.acceptance]);
329
+ for (const r of dropped)
330
+ doneRows.push(['dropped', title(r.title, format), r.acceptance]);
217
331
  lines.push(...table(['type', 'sujet', 'acceptance'], doneRows.length > 0 ? doneRows : [['-', 'aucun repère récent', '-']]));
218
332
  return lines.join('\n').trimEnd() + '\n';
219
333
  }
@@ -258,7 +372,7 @@ export function formatWpTree(tree, format = 'md') {
258
372
  * stops at sub-WP boundaries), so a flat walk over `node.leaves` is the true global total — never the
259
373
  * roots' rolled-up counts (which would double-count nested sub-WP leaves).
260
374
  */
261
- export function wpTotals(tree) {
375
+ export function wpTotals(tree, outsideRollup = []) {
262
376
  let done = 0;
263
377
  let active = 0;
264
378
  let dropped = 0;
@@ -279,6 +393,18 @@ export function wpTotals(tree) {
279
393
  };
280
394
  for (const node of tree)
281
395
  walk(node);
396
+ for (const row of outsideRollup) {
397
+ if (row.bucket === 'DONE') {
398
+ done++;
399
+ active++;
400
+ }
401
+ else if (row.bucket === 'AWAITED' || row.bucket === 'TO-DO') {
402
+ active++;
403
+ }
404
+ else {
405
+ dropped++;
406
+ }
407
+ }
282
408
  return { done, active, dropped, pct: active === 0 ? 'n/a' : Math.round((done / active) * 100) };
283
409
  }
284
410
  /** Open (non-DONE, non-DROPPED) leaves under a node — what À-FAIRE lists as `◦ <title>`. */
@@ -384,81 +510,751 @@ export function directivePhrase(d) {
384
510
  const suffix = d.step.code === 'resolve-external-blocker' || d.step.code === 'finish-increment' ? on() : '';
385
511
  return `action (${mode}): ${stepActionFr(d.step.code)}${suffix}`;
386
512
  }
387
- export function buildWpConductorView(tree, decisions = []) {
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 = {}) {
388
688
  const wpName = (n) => `${n.label} · ${clean(stripWpPrefix(n.title))}`;
389
- const totals = wpTotals(tree);
390
- // preconisation-actionnable (DESIGN §4): the DÉCISIONS/ACTIONS table + generalRecommendation are now
391
- // DERIVED from the directive set (each directive ⇒ one row, phrase rendered, never stored). FAIT/À-FAIRE
392
- // stay the same rollup-driven tables (unchanged back-compat).
689
+ const totals = wpTotals(tree, outsideRollup);
690
+ const wpNodes = [];
691
+ const collectWpNodes = (nodes) => {
692
+ for (const node of nodes) {
693
+ wpNodes.push(node);
694
+ collectWpNodes(node.children);
695
+ }
696
+ };
697
+ collectWpNodes(tree);
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
+ };
393
719
  const directives = buildDirectives(tree, decisions);
394
720
  const dispatchQueue = dispatchQueueOf(directives);
395
721
  const keystone = keystoneOf(tree);
396
- const humanDecisions = directives.filter((d) => d.mode === 'human-decision');
397
- const focusNeeded = humanDecisions.filter((d) => d.step.code === 'focus-decision').length;
398
- const generalRecommendation = focusNeeded >= 2 || humanDecisions.length >= 4
399
- ? 'Prévoir un temps de focus HTML pour trancher les décisions accumulées, puis reprendre les WP par premier item ouvert.'
400
- : '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');
401
745
  const doneRows = [
402
- { scope: 'global', progress: `${totals.done}/${totals.active} (${pctStr(totals.pct)})`, lastActions: `${totals.done} items faits; poursuivre les WP ouverts` },
403
- ...tree.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
+ : []),
404
777
  ];
405
- const todoRows = tree.filter((n) => n.pct !== 100).map((n) => {
406
- const open = openLeaves(n);
407
- const shown = open.slice(0, 2).map((l) => clean(l.title)).join(' / ');
408
- // NEVER truncate silently: surface the count of items not shown so the report can't read as complete.
409
- const more = open.length > 2 ? `${shown} (+${open.length - 2} autres)` : shown;
410
- return { wp: wpName(n), progress: `${n.done}/${n.active} (${pctStr(n.pct)})`, todo: more || '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
+ };
411
864
  });
412
- // DÉCISIONS/ACTIONS rowsderived from the directives. Decisions first (capped at 8 + an omission count
413
- // so the table never reads as complete when it isn't), then engagement/work directives.
414
- const actionRows = [];
415
- if (focusNeeded >= 2 || humanDecisions.length >= 4) {
416
- actionRows.push({ scope: '-', subject: 'décisions accumulées', recommendation: 'focus (humain+MCP): lancer focus HTML local; retour outcome via vue interactive/MCP' });
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) });
417
956
  }
418
- for (const d of humanDecisions.slice(0, 8)) {
419
- 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 });
420
989
  }
421
- for (const d of directives.filter((x) => x.mode !== 'human-decision')) {
422
- 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
+ });
423
997
  }
424
- const omitted = Math.max(0, humanDecisions.length - 8);
425
- if (omitted > 0) {
426
- actionRows.push({ scope: '-', subject: `+${omitted} entrées non listées`, recommendation: 'voir `track query` / `track report --flat` pour le détail complet' });
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.');
427
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
+ };
428
1077
  return {
429
1078
  kind: 'wp-conductor-report',
430
1079
  locale: 'fr',
1080
+ header,
431
1081
  tables: [
432
- { id: 'done', title: 'FAIT', columns: [{ id: 'scope', label: 'scope' }, { id: 'progress', label: 'avancement' }, { id: 'lastActions', label: 'dernières actions' }], rows: doneRows },
433
- { 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' }] },
434
- { 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
+ },
435
1124
  ],
436
- generalRecommendation,
1125
+ handles,
1126
+ coverage,
437
1127
  directives,
1128
+ directivesProjection: { kind: 'conductor-action-directives', order: 'canonical-urgency' },
438
1129
  dispatchQueue,
1130
+ dispatchQueueProjection: { kind: 'delegable-directive-ids', order: 'canonical-urgency', modes: ['subagent', 'local'] },
439
1131
  ...(keystone !== undefined ? { keystone } : {}),
440
1132
  };
441
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
+ }
442
1196
  function renderReportView(view, format) {
443
1197
  if (format === 'json')
444
1198
  return JSON.stringify(view, null, 2) + '\n';
445
1199
  // User-originated cell content (titles) is escaped per-format: `md` escapes markdown metacharacters so a
446
1200
  // crafted item title cannot inject formatting (parity with the legacy `formatReport`/`title` path); `text`
447
- // is clean. The view model itself stays RAW (escaping is a render-only concern).
448
- 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);
449
1204
  const h = (label) => (format === 'md' ? `## ${label}` : label);
450
- const lines = [];
1205
+ const lines = headerLines(view, format);
451
1206
  for (const section of view.tables) {
452
1207
  lines.push(h(section.title));
453
- 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
+ }
454
1228
  lines.push('');
455
1229
  }
456
- lines.push(h('RECOMMANDATION'));
457
- 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);
458
1237
  return lines.join('\n').trimEnd() + '\n';
459
1238
  }
460
- export function formatWpConductor(tree, format, decisions = []) {
461
- return renderReportView(buildWpConductorView(tree, decisions), 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);
462
1258
  }
463
1259
  /** Clean + hard-truncate a line to `width` with a trailing ellipsis (never a silent cut mid-report). */
464
1260
  function truncateLine(s, width) {
@@ -477,21 +1273,29 @@ function fitMiddle(head, mid, tail, width) {
477
1273
  const m = mid.length <= budget ? mid : `${mid.slice(0, Math.max(1, budget - 1))}…`;
478
1274
  return head + m + tail;
479
1275
  }
480
- export function formatWpConductorInline(tree, decisions = [], opts = {}) {
481
- const width = Math.min(200, Math.max(48, opts.width ?? 80));
1276
+ export function formatWpConductorInline(tree, decisions = [], opts = {}, outsideRollup = []) {
1277
+ const width = Math.min(240, Math.max(40, opts.width ?? 80));
482
1278
  const maxDir = Math.max(1, opts.maxDirectives ?? 10);
483
- const view = buildWpConductorView(tree, decisions);
484
- const totals = wpTotals(tree);
1279
+ const view = buildWpConductorView(tree, decisions, outsideRollup);
1280
+ const totals = wpTotals(tree, outsideRollup);
485
1281
  const wpName = (n) => `${n.label} · ${clean(stripWpPrefix(n.title))}`;
1282
+ const wpNodes = [];
1283
+ const collectWpNodes = (nodes) => {
1284
+ for (const node of nodes) {
1285
+ wpNodes.push(node);
1286
+ collectWpNodes(node.children);
1287
+ }
1288
+ };
1289
+ collectWpNodes(tree);
486
1290
  const lines = [];
487
1291
  // FAIT — one line: global progress + the closed WPs (by label).
488
- const closed = tree.filter((n) => n.pct === 100).map((n) => n.label);
489
- lines.push(truncateLine(`FAIT ${totals.done}/${totals.active} (${pctStr(totals.pct)})${closed.length > 0 ? ` · clos: ${closed.join(', ')}` : ''}`, width));
1292
+ const closed = wpNodes.filter((n) => n.pct === 100).map((n) => n.label);
1293
+ 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));
490
1294
  // À-FAIRE — one line per open WP that actually HAS open direct work; open leaves cohort-collapsed
491
1295
  // (N× tag) so it stays on screen. WPs/streams with no open direct item ("0/0 — aucun item ouvert")
492
1296
  // are pure noise (their remaining work, if any, shows under a child WP) and are dropped.
493
1297
  lines.push('À-FAIRE');
494
- const openWps = tree.filter((n) => n.pct !== 100);
1298
+ const openWps = wpNodes.filter((n) => n.pct !== 100);
495
1299
  let shown = 0;
496
1300
  for (const n of openWps) {
497
1301
  const cohorts = collapseLeafCohorts(openLeaves(n));