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