@sentropic/track 0.88.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/cli/index.d.ts.map +1 -1
  2. package/dist/cli/index.js +175 -28
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/events/types.d.ts +1 -1
  5. package/dist/events/types.d.ts.map +1 -1
  6. package/dist/events/types.js +16 -0
  7. package/dist/events/types.js.map +1 -1
  8. package/dist/events/validate.d.ts +10 -0
  9. package/dist/events/validate.d.ts.map +1 -1
  10. package/dist/events/validate.js +81 -0
  11. package/dist/events/validate.js.map +1 -1
  12. package/dist/ingest/contract.d.ts +3 -2
  13. package/dist/ingest/contract.d.ts.map +1 -1
  14. package/dist/ingest/contract.js +33 -1
  15. package/dist/ingest/contract.js.map +1 -1
  16. package/dist/ingest/index.d.ts.map +1 -1
  17. package/dist/ingest/index.js +6 -5
  18. package/dist/ingest/index.js.map +1 -1
  19. package/dist/ingest/ingest.d.ts.map +1 -1
  20. package/dist/ingest/ingest.js +17 -0
  21. package/dist/ingest/ingest.js.map +1 -1
  22. package/dist/ingest/map.d.ts.map +1 -1
  23. package/dist/ingest/map.js +11 -0
  24. package/dist/ingest/map.js.map +1 -1
  25. package/dist/model/index.d.ts +1 -1
  26. package/dist/model/index.d.ts.map +1 -1
  27. package/dist/model/index.js +1 -1
  28. package/dist/model/index.js.map +1 -1
  29. package/dist/model/item.d.ts +65 -0
  30. package/dist/model/item.d.ts.map +1 -1
  31. package/dist/model/item.js +49 -0
  32. package/dist/model/item.js.map +1 -1
  33. package/dist/read/commands.d.ts +23 -11
  34. package/dist/read/commands.d.ts.map +1 -1
  35. package/dist/read/commands.js +142 -36
  36. package/dist/read/commands.js.map +1 -1
  37. package/dist/read/contract.d.ts +21 -3
  38. package/dist/read/contract.d.ts.map +1 -1
  39. package/dist/read/contract.js +26 -2
  40. package/dist/read/contract.js.map +1 -1
  41. package/dist/report/blocker-status.d.ts.map +1 -1
  42. package/dist/report/blocker-status.js +11 -0
  43. package/dist/report/blocker-status.js.map +1 -1
  44. package/dist/report/format.d.ts +55 -1
  45. package/dist/report/format.d.ts.map +1 -1
  46. package/dist/report/format.js +203 -72
  47. package/dist/report/format.js.map +1 -1
  48. package/dist/report/index.d.ts +0 -1
  49. package/dist/report/index.d.ts.map +1 -1
  50. package/dist/report/index.js +0 -1
  51. package/dist/report/index.js.map +1 -1
  52. package/dist/state/fold.d.ts.map +1 -1
  53. package/dist/state/fold.js +48 -0
  54. package/dist/state/fold.js.map +1 -1
  55. package/dist/track.d.ts +34 -1
  56. package/dist/track.d.ts.map +1 -1
  57. package/dist/track.js +95 -2
  58. package/dist/track.js.map +1 -1
  59. package/package.json +2 -2
  60. package/skills/track-operation/SKILL.md +16 -4
  61. package/dist/report/ai-report.d.ts +0 -122
  62. package/dist/report/ai-report.d.ts.map +0 -1
  63. package/dist/report/ai-report.js +0 -727
  64. package/dist/report/ai-report.js.map +0 -1
  65. package/dist/report/html.d.ts +0 -9
  66. package/dist/report/html.d.ts.map +0 -1
  67. package/dist/report/html.js +0 -73
  68. package/dist/report/html.js.map +0 -1
  69. package/dist/report/present.d.ts +0 -28
  70. package/dist/report/present.d.ts.map +0 -1
  71. package/dist/report/present.js +0 -32
  72. package/dist/report/present.js.map +0 -1
@@ -4,6 +4,12 @@ import { buildDirectives, decisionNeedsFocus, dispatchQueueOf, keystoneOf, } fro
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
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([
@@ -483,7 +489,7 @@ export function collapseLeafCohorts(leaves) {
483
489
  export function directivePhrase(d) {
484
490
  // §A6 — a directive renders a CONCRETE next move + the actor, and NAMES what blocks (the info the
485
491
  // `sujet` column does NOT already carry). All interpolated titles/refs are RAW here — the render layer
486
- // (table `esc`, inline `clean`, html `escapeHtml`) escapes them (§A4). A decision surfaces as a "décision"
492
+ // (table `esc`, inline `clean`) escapes them (§A4). A decision surfaces as a "décision"
487
493
  // line ONLY when it genuinely blocks (mode `human-decision`); otherwise the phrase préconise a step.
488
494
  //
489
495
  // Unified presentation (spec 2026-07-11) — the action CLAUSE is the shared canonical `stepActionFr`, so
@@ -543,6 +549,26 @@ function recentDoneLeaves(node) {
543
549
  walk(node);
544
550
  return out.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
545
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
+ }
546
572
  const LAST_ACTIONS_SHOWN = 3;
547
573
  /**
548
574
  * FAIT's third column (criteria 3/4/22/26/27).
@@ -587,12 +613,34 @@ const FOCUS_ROWS = 5;
587
613
  * request (`--sub-wp`).
588
614
  */
589
615
  const LONG_WINDOW_DAYS = 14;
590
- function windowDays(period) {
591
- if (period.from === undefined || period.to === undefined)
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)
592
640
  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);
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;
596
644
  }
597
645
  /**
598
646
  * How much of an item's RECORDED body the `à faire` cell shows. Tight on purpose: one clause, enough to
@@ -600,9 +648,27 @@ function windowDays(period) {
600
648
  * reads it as the full record — the same honesty the old `extrait` column applied.
601
649
  */
602
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
+ }
603
668
  /** `undefined` for an absent/blank body — a bare title is then the HONEST render, not a gap to fill. */
604
669
  export function todoExcerpt(body) {
605
- const cleaned = body === undefined ? undefined : clean(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));
606
672
  if (cleaned === undefined || cleaned === '')
607
673
  return undefined;
608
674
  if (cleaned.length <= TODO_EXCERPT_MAX)
@@ -644,7 +710,25 @@ function isoDate(value) {
644
710
  return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString().slice(0, 10);
645
711
  }
646
712
  /** Criterion 21 — the period, always bounded, always read from the log (plus the caller's clock). */
647
- function buildPeriod(meta) {
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
+ }
648
732
  const from = isoDate(meta.logFrom);
649
733
  const now = isoDate(meta.now);
650
734
  const last = isoDate(meta.logTo);
@@ -661,6 +745,23 @@ function buildPeriod(meta) {
661
745
  label,
662
746
  };
663
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
+ }
664
765
  const OPTION_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
665
766
  const optionLetter = (index) => OPTION_LETTERS[index] ?? `#${index + 1}`;
666
767
  /**
@@ -684,7 +785,11 @@ function compactRefs(refs) {
684
785
  }
685
786
  return [...parts, ...other].join(' · ');
686
787
  }
788
+ function shellArgument(value) {
789
+ return /^[A-Za-z0-9_./:-]+$/u.test(value) ? value : `'${value.replace(/'/gu, "'\"'\"'")}'`;
790
+ }
687
791
  export function buildWpConductorView(tree, decisions = [], outsideRollup = [], totalScope = 'global', meta = {}) {
792
+ assertOrderedPeriodWindow(meta);
688
793
  const wpName = (n) => `${n.label} · ${clean(stripWpPrefix(n.title))}`;
689
794
  const totals = wpTotals(tree, outsideRollup);
690
795
  const wpNodes = [];
@@ -700,8 +805,8 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
700
805
  // into their root — their leaves already roll up (`openLeaves`/`recentDoneLeaves` walk children), so
701
806
  // nothing is lost; only their row disappears. The aggregation is DECLARED in the header, like every
702
807
  // other compression in this report.
703
- const period = buildPeriod(meta);
704
- const days = windowDays(period);
808
+ const period = reportPeriod(meta);
809
+ const days = windowDays(meta);
705
810
  const subWpDetail = meta.subWp === true || (days !== undefined && days < LONG_WINDOW_DAYS);
706
811
  const rowNodes = subWpDetail ? wpNodes : [...tree];
707
812
  const subNodes = wpNodes.filter((n) => !rowNodes.includes(n));
@@ -741,13 +846,16 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
741
846
  // of the page maps every emitted handle to its item id, and no ULID enters a column the owner reads.
742
847
  const handles = [];
743
848
  // ---- FAIT ---------------------------------------------------------------------------------------
744
- const outsideDone = outsideRollup.filter((r) => r.bucket === 'DONE' || r.bucket === 'DROPPED');
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);
745
853
  const doneRows = [
746
854
  {
747
855
  scope: totalScope,
748
856
  progress: `${totals.done}/${totals.active} (${pctStr(totals.pct)})`,
749
857
  lastActions: lastActionsCell(wpNodes
750
- .flatMap((n) => n.leaves.filter((l) => l.bucket === 'DONE'))
858
+ .flatMap((n) => meta.deliveredItemIds === undefined ? n.leaves.filter((l) => l.bucket === 'DONE') : n.leaves.filter((l) => meta.deliveredItemIds.has(l.id)))
751
859
  .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
752
860
  .map((l) => clean(l.title))),
753
861
  },
@@ -755,17 +863,19 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
755
863
  // window is short or the owner asked. `recentDoneLeaves` already walks children, so a root's row
756
864
  // carries its sub-levels' deliveries rather than losing them.
757
865
  ...rowNodes
758
- .filter((n) => n.done > 0)
866
+ .filter((n) => periodLeaves(n).length > 0)
759
867
  .map((n) => ({
760
868
  scope: wpName(n),
761
869
  progress: `${n.done}/${n.active} (${pctStr(n.pct)})`,
762
- lastActions: lastActionsCell(recentDoneLeaves(n).map((l) => clean(l.title))),
870
+ lastActions: lastActionsCell(periodLeaves(n).map((l) => clean(l.title))),
763
871
  })),
764
872
  ...(outsideDone.length > 0
765
873
  ? [{
766
874
  scope: 'hors WP',
767
875
  progress: (() => {
768
- const done = outsideRollup.filter((r) => r.bucket === 'DONE').length;
876
+ const done = meta.deliveredItemIds === undefined
877
+ ? outsideRollup.filter((r) => r.bucket === 'DONE').length
878
+ : outsideRollup.filter((r) => meta.deliveredItemIds.has(r.id)).length;
769
879
  const active = outsideRollup.filter((r) => r.bucket !== 'DROPPED').length;
770
880
  return `${done}/${active} (${pctStr(active === 0 ? 'n/a' : Math.round((done / active) * 100))})`;
771
881
  })(),
@@ -838,7 +948,10 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
838
948
  // A directive may target a DONE leaf with acceptance debt: name it here (with its own handle)
839
949
  // instead of exiling it to a `cible action` column the owner never asked for.
840
950
  for (const d of attached) {
841
- if (d.target.kind === 'decision' || items.some((i) => i.id === d.target.id))
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))
842
955
  continue;
843
956
  const debtLeaf = wpNodes.flatMap((node) => node.leaves).find((l) => l.id === d.target.id);
844
957
  items.push({
@@ -972,7 +1085,7 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
972
1085
  const selected = d.selectedOptionId === undefined ? undefined : letterOf.get(d.selectedOptionId);
973
1086
  options.forEach((option, i) => {
974
1087
  const letter = optionLetter(i);
975
- const wrapped = wrapCell(`${letter} ${clean(option.title)} — ${clean(option.summary)}`, DECISION_CAPS[2]);
1088
+ const wrapped = wrapCell(`${letter} ${redactOwnerText(clean(option.title))} — ${redactOwnerText(clean(option.summary))}`, DECISION_CAPS[2]);
976
1089
  const marks = [];
977
1090
  if (letter === recommended)
978
1091
  marks.push(letter);
@@ -1066,62 +1179,77 @@ export function buildWpConductorView(tree, decisions = [], outsideRollup = [], t
1066
1179
  };
1067
1180
  const header = {
1068
1181
  scope: totalScope,
1182
+ ...(meta.scopeProjection !== undefined ? { scopeProjection: meta.scopeProjection } : {}),
1183
+ ...(meta.journalRevision !== undefined ? { journalRevision: meta.journalRevision } : {}),
1069
1184
  progress: `${totals.done}/${totals.active} (${pctStr(totals.pct)})`,
1070
1185
  ...(meta.baselineCommit !== undefined ? { baselineCommit: meta.baselineCommit.slice(0, 12) } : {}),
1071
1186
  // Criterion 21 — the window is measured in the log, so it is always stated, always with dates.
1072
1187
  period,
1073
- sources: ['projection déterministe du journal (track report --wp --decisions)'],
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
+ ],
1074
1199
  coverage,
1075
- handleCommand: 'track report --resolve <handle>',
1200
+ handleCommand: meta.scopeProjection === undefined
1201
+ ? 'track report --resolve <handle>'
1202
+ : `track report --scope ${shellArgument(meta.scopeProjection.selector)} --resolve <handle>`,
1076
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
+ ];
1077
1248
  return {
1078
1249
  kind: 'wp-conductor-report',
1079
1250
  locale: 'fr',
1080
1251
  header,
1081
- tables: [
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
- },
1124
- ],
1252
+ tables: tables.map(redactOwnerTable),
1125
1253
  handles,
1126
1254
  coverage,
1127
1255
  directives,
@@ -1172,7 +1300,7 @@ function headerLines(view, format) {
1172
1300
  em(`sources : ${h.sources.join(' ; ')}`),
1173
1301
  '',
1174
1302
  ];
1175
- return lines;
1303
+ return lines.map(redactOwnerText);
1176
1304
  }
1177
1305
  /**
1178
1306
  * Criteria 10b/10c — the machine's half of the page. It is NOT a fifth section and NOT a table the owner
@@ -1194,12 +1322,11 @@ export function resolutionLines(view) {
1194
1322
  return lines;
1195
1323
  }
1196
1324
  function renderReportView(view, format) {
1325
+ assertReportFormat(format);
1197
1326
  if (format === 'json')
1198
1327
  return JSON.stringify(view, null, 2) + '\n';
1199
- // User-originated cell content (titles) is escaped per-format: `md` escapes markdown metacharacters so a
1200
- // crafted item title cannot inject formatting (parity with the legacy `formatReport`/`title` path); `text`
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.
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.
1203
1330
  const esc = (s) => displayCell(s, format);
1204
1331
  const h = (label) => (format === 'md' ? `## ${label}` : label);
1205
1332
  const lines = headerLines(view, format);
@@ -1258,7 +1385,7 @@ export function formatWpConductor(tree, format, decisions = [], outsideRollup =
1258
1385
  }
1259
1386
  /** Clean + hard-truncate a line to `width` with a trailing ellipsis (never a silent cut mid-report). */
1260
1387
  function truncateLine(s, width) {
1261
- const c = clean(s);
1388
+ const c = redactOwnerText(clean(s));
1262
1389
  return c.length <= width ? c : `${c.slice(0, Math.max(1, width - 1))}…`;
1263
1390
  }
1264
1391
  /**
@@ -1271,12 +1398,12 @@ function fitMiddle(head, mid, tail, width) {
1271
1398
  if (budget < 8)
1272
1399
  return truncateLine(head + mid + tail, width);
1273
1400
  const m = mid.length <= budget ? mid : `${mid.slice(0, Math.max(1, budget - 1))}…`;
1274
- return head + m + tail;
1401
+ return redactOwnerText(head + m + tail);
1275
1402
  }
1276
- export function formatWpConductorInline(tree, decisions = [], opts = {}, outsideRollup = []) {
1403
+ export function formatWpConductorInline(tree, decisions = [], opts = {}, outsideRollup = [], meta) {
1277
1404
  const width = Math.min(240, Math.max(40, opts.width ?? 80));
1278
1405
  const maxDir = Math.max(1, opts.maxDirectives ?? 10);
1279
- const view = buildWpConductorView(tree, decisions, outsideRollup);
1406
+ const view = buildWpConductorView(tree, decisions, outsideRollup, 'global', meta);
1280
1407
  const totals = wpTotals(tree, outsideRollup);
1281
1408
  const wpName = (n) => `${n.label} · ${clean(stripWpPrefix(n.title))}`;
1282
1409
  const wpNodes = [];
@@ -1288,6 +1415,10 @@ export function formatWpConductorInline(tree, decisions = [], opts = {}, outside
1288
1415
  };
1289
1416
  collectWpNodes(tree);
1290
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));
1291
1422
  // FAIT — one line: global progress + the closed WPs (by label).
1292
1423
  const closed = wpNodes.filter((n) => n.pct === 100).map((n) => n.label);
1293
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));