@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.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +209 -20
- package/dist/cli/index.js.map +1 -1
- package/dist/events/types.d.ts +1 -1
- package/dist/events/types.d.ts.map +1 -1
- package/dist/events/types.js +16 -0
- package/dist/events/types.js.map +1 -1
- package/dist/events/validate.d.ts +10 -0
- package/dist/events/validate.d.ts.map +1 -1
- package/dist/events/validate.js +81 -0
- package/dist/events/validate.js.map +1 -1
- package/dist/ingest/contract.d.ts +3 -2
- package/dist/ingest/contract.d.ts.map +1 -1
- package/dist/ingest/contract.js +33 -1
- package/dist/ingest/contract.js.map +1 -1
- package/dist/ingest/index.d.ts.map +1 -1
- package/dist/ingest/index.js +6 -5
- package/dist/ingest/index.js.map +1 -1
- package/dist/ingest/ingest.d.ts.map +1 -1
- package/dist/ingest/ingest.js +17 -0
- package/dist/ingest/ingest.js.map +1 -1
- package/dist/ingest/map.d.ts.map +1 -1
- package/dist/ingest/map.js +11 -0
- package/dist/ingest/map.js.map +1 -1
- package/dist/model/index.d.ts +1 -1
- package/dist/model/index.d.ts.map +1 -1
- package/dist/model/index.js +1 -1
- package/dist/model/index.js.map +1 -1
- package/dist/model/item.d.ts +65 -0
- package/dist/model/item.d.ts.map +1 -1
- package/dist/model/item.js +49 -0
- package/dist/model/item.js.map +1 -1
- package/dist/read/commands.d.ts +24 -18
- package/dist/read/commands.d.ts.map +1 -1
- package/dist/read/commands.js +167 -26
- package/dist/read/commands.js.map +1 -1
- package/dist/read/contract.d.ts +32 -3
- package/dist/read/contract.d.ts.map +1 -1
- package/dist/read/contract.js +42 -2
- package/dist/read/contract.js.map +1 -1
- package/dist/report/blocker-status.d.ts.map +1 -1
- package/dist/report/blocker-status.js +11 -0
- package/dist/report/blocker-status.js.map +1 -1
- package/dist/report/build.d.ts +6 -0
- package/dist/report/build.d.ts.map +1 -1
- package/dist/report/build.js +2 -31
- package/dist/report/build.js.map +1 -1
- package/dist/report/format.d.ts +227 -8
- package/dist/report/format.d.ts.map +1 -1
- package/dist/report/format.js +946 -106
- package/dist/report/format.js.map +1 -1
- package/dist/report/index.d.ts +1 -1
- package/dist/report/index.d.ts.map +1 -1
- package/dist/report/index.js +1 -1
- package/dist/report/index.js.map +1 -1
- package/dist/report/rollup.d.ts +14 -0
- package/dist/report/rollup.d.ts.map +1 -1
- package/dist/report/rollup.js +32 -0
- package/dist/report/rollup.js.map +1 -1
- package/dist/report/snapshot.d.ts +20 -1
- package/dist/report/snapshot.d.ts.map +1 -1
- package/dist/report/snapshot.js +9 -2
- package/dist/report/snapshot.js.map +1 -1
- package/dist/state/fold.d.ts.map +1 -1
- package/dist/state/fold.js +48 -0
- package/dist/state/fold.js.map +1 -1
- package/dist/track.d.ts +34 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +95 -2
- package/dist/track.js.map +1 -1
- package/package.json +1 -1
- package/skills/track-operation/SKILL.md +16 -4
- package/dist/report/html.d.ts +0 -9
- package/dist/report/html.d.ts.map +0 -1
- package/dist/report/html.js +0 -76
- package/dist/report/html.js.map +0 -1
- package/dist/report/present.d.ts +0 -28
- package/dist/report/present.d.ts.map +0 -1
- package/dist/report/present.js +0 -32
- package/dist/report/present.js.map +0 -1
package/dist/report/format.js
CHANGED
|
@@ -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
|
-
/**
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
{
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
978
|
+
// Criterion 24 — a 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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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));
|