@arka-labs/nemesis 1.2.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 (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +668 -0
  3. package/lib/core/agent-launcher.js +193 -0
  4. package/lib/core/audit.js +210 -0
  5. package/lib/core/connexions.js +80 -0
  6. package/lib/core/flowmap/api.js +111 -0
  7. package/lib/core/flowmap/cli-helpers.js +80 -0
  8. package/lib/core/flowmap/machine.js +281 -0
  9. package/lib/core/flowmap/persistence.js +83 -0
  10. package/lib/core/generators.js +183 -0
  11. package/lib/core/inbox.js +275 -0
  12. package/lib/core/logger.js +20 -0
  13. package/lib/core/mission.js +109 -0
  14. package/lib/core/notewriter/config.js +36 -0
  15. package/lib/core/notewriter/cr.js +237 -0
  16. package/lib/core/notewriter/log.js +112 -0
  17. package/lib/core/notewriter/notes.js +168 -0
  18. package/lib/core/notewriter/paths.js +45 -0
  19. package/lib/core/notewriter/reader.js +121 -0
  20. package/lib/core/notewriter/registry.js +80 -0
  21. package/lib/core/odm.js +191 -0
  22. package/lib/core/profile-picker.js +323 -0
  23. package/lib/core/project.js +287 -0
  24. package/lib/core/registry.js +129 -0
  25. package/lib/core/secrets.js +137 -0
  26. package/lib/core/services.js +45 -0
  27. package/lib/core/team.js +287 -0
  28. package/lib/core/templates.js +80 -0
  29. package/lib/kairos/agent-runner.js +261 -0
  30. package/lib/kairos/claude-invoker.js +90 -0
  31. package/lib/kairos/context-injector.js +331 -0
  32. package/lib/kairos/context-loader.js +108 -0
  33. package/lib/kairos/context-writer.js +45 -0
  34. package/lib/kairos/dispatcher-router.js +173 -0
  35. package/lib/kairos/dispatcher.js +139 -0
  36. package/lib/kairos/event-bus.js +287 -0
  37. package/lib/kairos/event-router.js +131 -0
  38. package/lib/kairos/flowmap-bridge.js +120 -0
  39. package/lib/kairos/hook-handlers.js +351 -0
  40. package/lib/kairos/hook-installer.js +207 -0
  41. package/lib/kairos/hook-prompts.js +54 -0
  42. package/lib/kairos/leader-rules.js +94 -0
  43. package/lib/kairos/pid-checker.js +108 -0
  44. package/lib/kairos/situation-detector.js +123 -0
  45. package/lib/sync/fallback-engine.js +97 -0
  46. package/lib/sync/hcm-client.js +170 -0
  47. package/lib/sync/health.js +47 -0
  48. package/lib/sync/llm-client.js +387 -0
  49. package/lib/sync/nemesis-client.js +379 -0
  50. package/lib/sync/service-session.js +74 -0
  51. package/lib/sync/sync-engine.js +178 -0
  52. package/lib/ui/box.js +104 -0
  53. package/lib/ui/brand.js +42 -0
  54. package/lib/ui/colors.js +57 -0
  55. package/lib/ui/dashboard.js +580 -0
  56. package/lib/ui/error-hints.js +49 -0
  57. package/lib/ui/format.js +61 -0
  58. package/lib/ui/menu.js +306 -0
  59. package/lib/ui/note-card.js +198 -0
  60. package/lib/ui/note-colors.js +26 -0
  61. package/lib/ui/note-detail.js +297 -0
  62. package/lib/ui/note-filters.js +252 -0
  63. package/lib/ui/note-views.js +283 -0
  64. package/lib/ui/prompt.js +81 -0
  65. package/lib/ui/spinner.js +139 -0
  66. package/lib/ui/streambox.js +46 -0
  67. package/lib/ui/table.js +42 -0
  68. package/lib/ui/tree.js +33 -0
  69. package/package.json +53 -0
  70. package/src/cli.js +457 -0
  71. package/src/commands/_helpers.js +119 -0
  72. package/src/commands/audit.js +187 -0
  73. package/src/commands/auth.js +316 -0
  74. package/src/commands/doctor.js +243 -0
  75. package/src/commands/hcm.js +147 -0
  76. package/src/commands/inbox.js +333 -0
  77. package/src/commands/init.js +160 -0
  78. package/src/commands/kairos.js +216 -0
  79. package/src/commands/kars.js +134 -0
  80. package/src/commands/mission.js +275 -0
  81. package/src/commands/notes.js +316 -0
  82. package/src/commands/notewriter.js +296 -0
  83. package/src/commands/odm.js +329 -0
  84. package/src/commands/orch.js +68 -0
  85. package/src/commands/project.js +123 -0
  86. package/src/commands/run.js +123 -0
  87. package/src/commands/services.js +705 -0
  88. package/src/commands/status.js +231 -0
  89. package/src/commands/team.js +572 -0
  90. package/src/config.js +84 -0
  91. package/src/index.js +5 -0
  92. package/templates/project-context.json +10 -0
  93. package/templates/template_CONTRIB-NAME.json +22 -0
  94. package/templates/template_CR-ODM-NAME-000.exemple.json +32 -0
  95. package/templates/template_DEC-NAME-000.json +18 -0
  96. package/templates/template_INTV-NAME-000.json +15 -0
  97. package/templates/template_MISSION_CONTRACT.json +46 -0
  98. package/templates/template_ODM-NAME-000.json +89 -0
  99. package/templates/template_REGISTRY-PROJECT.json +26 -0
  100. package/templates/template_TXN-NAME-000.json +24 -0
package/lib/ui/menu.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Interactive keyboard-navigable menu — zero deps.
3
+ * Uses readline.emitKeypressEvents for reliable arrow key handling.
4
+ * Arrow keys (up/down), Enter to select, q/Ctrl+C to quit.
5
+ * Supports scrollable viewport via maxVisible option.
6
+ * '/' activates filter mode for substring search.
7
+ */
8
+
9
+ import { emitKeypressEvents } from 'node:readline';
10
+ import { style, stripAnsi } from './colors.js';
11
+
12
+ /**
13
+ * Pure function — filter items by substring (case-insensitive) on label + description.
14
+ * @param {Array<{label: string, value: string, description?: string}>} items
15
+ * @param {string} text
16
+ * @returns {Array<{label: string, value: string, description?: string, _origIndex: number}>}
17
+ */
18
+ export function filterItems(items, text) {
19
+ if (!text) return items.map((item, i) => ({ ...item, _origIndex: i }));
20
+ const lower = text.toLowerCase();
21
+ return items
22
+ .map((item, i) => ({ ...item, _origIndex: i }))
23
+ .filter(item => {
24
+ const haystack = (stripAnsi(item.label) + ' ' + (item.description || '')).toLowerCase();
25
+ return haystack.includes(lower);
26
+ });
27
+ }
28
+
29
+ /**
30
+ * @param {Array<{label: string, value: string, description?: string}>} items
31
+ * @param {object} opts
32
+ * @param {string} [opts.title]
33
+ * @param {string} [opts.hint]
34
+ * @param {number} [opts.maxVisible] — 0 = show all (default)
35
+ * @returns {Promise<string|null>} selected value or null if cancelled
36
+ */
37
+ export function interactiveMenu(items, opts = {}) {
38
+ const {
39
+ title,
40
+ hint: customHint,
41
+ maxVisible = 0,
42
+ } = opts;
43
+
44
+ const defaultHint = 'Fleches \u2191\u2193 naviguer, Enter selectionner, / filtrer, q quitter';
45
+
46
+ return new Promise(resolve => {
47
+ let cursor = 0;
48
+ let viewOffset = 0;
49
+ let filterMode = false;
50
+ let filterText = '';
51
+ let filteredItems = items;
52
+ const { stdin, stdout } = process;
53
+
54
+ if (!stdin.isTTY) {
55
+ resolve(null);
56
+ return;
57
+ }
58
+
59
+ function getVisibleItems() {
60
+ return filterMode ? filteredItems : items;
61
+ }
62
+
63
+ function getEffectiveMax() {
64
+ const vis = getVisibleItems();
65
+ return maxVisible > 0 && maxVisible < vis.length ? maxVisible : vis.length;
66
+ }
67
+
68
+ function getNeedsScroll() {
69
+ return getVisibleItems().length > getEffectiveMax();
70
+ }
71
+
72
+ emitKeypressEvents(stdin);
73
+ stdin.setRawMode(true);
74
+ stdin.resume();
75
+ stdin.setEncoding('utf8');
76
+
77
+ // Fixed line count — scroll indicators always reserve space when scrollable
78
+ function fixedLineCount() {
79
+ let count = 0;
80
+ if (title) count += 2; // title + blank
81
+ if (getNeedsScroll()) count += 2; // ▲ + ▼ (or placeholders)
82
+ count += getEffectiveMax(); // visible items
83
+ count += 2; // blank + hint
84
+ if (filterMode) count += 1; // filter bar
85
+ return count;
86
+ }
87
+
88
+ function adjustViewport() {
89
+ const vis = getVisibleItems();
90
+ const eMax = getEffectiveMax();
91
+ if (vis.length === 0) { viewOffset = 0; return; }
92
+ if (cursor < viewOffset) viewOffset = cursor;
93
+ if (cursor >= viewOffset + eMax) viewOffset = cursor - eMax + 1;
94
+ }
95
+
96
+ function draw() {
97
+ const vis = getVisibleItems();
98
+ const eMax = getEffectiveMax();
99
+ const needsScroll = getNeedsScroll();
100
+
101
+ if (title) {
102
+ stdout.write(` ${style.bold(title)}\n`);
103
+ stdout.write('\n');
104
+ }
105
+
106
+ // Scroll up indicator
107
+ if (needsScroll) {
108
+ if (viewOffset > 0) {
109
+ stdout.write(` ${style.dim(`\u25B2 ${viewOffset} de plus`)}\n`);
110
+ } else {
111
+ stdout.write('\n');
112
+ }
113
+ }
114
+
115
+ // Visible items
116
+ if (vis.length === 0) {
117
+ for (let i = 0; i < eMax || i < 1; i++) {
118
+ if (i === 0) {
119
+ stdout.write(` ${style.dim('(aucun resultat)')}\n`);
120
+ } else {
121
+ stdout.write('\n');
122
+ }
123
+ }
124
+ } else {
125
+ for (let i = 0; i < eMax; i++) {
126
+ const realIndex = viewOffset + i;
127
+ if (realIndex >= vis.length) {
128
+ stdout.write('\n');
129
+ } else {
130
+ const item = vis[realIndex];
131
+ const active = realIndex === cursor;
132
+ const marker = active ? style.arkaRed('\u276f') : ' ';
133
+ const label = active ? style.bold(item.label) : item.label;
134
+ const desc = item.description ? style.gray(` \u2014 ${item.description}`) : '';
135
+ stdout.write(` ${marker} ${label}${desc}\n`);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Scroll down indicator
141
+ if (needsScroll) {
142
+ const remaining = vis.length - viewOffset - eMax;
143
+ if (remaining > 0) {
144
+ stdout.write(` ${style.dim(`\u25BC ${remaining} de plus`)}\n`);
145
+ } else {
146
+ stdout.write('\n');
147
+ }
148
+ }
149
+
150
+ // Hint
151
+ const hint = customHint || defaultHint;
152
+ stdout.write(`\n ${style.dim(hint)}\n`);
153
+
154
+ // Filter bar
155
+ if (filterMode) {
156
+ stdout.write(` ${style.nemesisAccent('/')} ${filterText}${style.dim('_')}\n`);
157
+ }
158
+ }
159
+
160
+ function redraw() {
161
+ stdout.write(`\x1b[${fixedLineCount()}A\x1b[J`);
162
+ draw();
163
+ }
164
+
165
+ function cleanup() {
166
+ stdin.removeListener('keypress', onKeypress);
167
+ stdin.setRawMode(false);
168
+ stdin.pause();
169
+ stdout.write(`\x1b[${fixedLineCount() + 1}A\x1b[J`);
170
+ }
171
+
172
+ function applyFilter() {
173
+ filteredItems = filterItems(items, filterText);
174
+ cursor = 0;
175
+ viewOffset = 0;
176
+ }
177
+
178
+ function onKeypress(ch, key) {
179
+ if (!key) return;
180
+
181
+ // Ctrl+C — always quit
182
+ if (key.ctrl && key.name === 'c') {
183
+ cleanup();
184
+ resolve(null);
185
+ return;
186
+ }
187
+
188
+ // --- Filter mode ---
189
+ if (filterMode) {
190
+ // Escape — exit filter mode
191
+ if (key.name === 'escape') {
192
+ filterMode = false;
193
+ filterText = '';
194
+ filteredItems = items;
195
+ cursor = 0;
196
+ viewOffset = 0;
197
+ redraw();
198
+ return;
199
+ }
200
+
201
+ // Enter — select current item in filtered list
202
+ if (key.name === 'return') {
203
+ const vis = getVisibleItems();
204
+ if (vis.length > 0) {
205
+ cleanup();
206
+ stdout.write('\n');
207
+ resolve(vis[cursor].value);
208
+ }
209
+ return;
210
+ }
211
+
212
+ // Backspace — delete last char
213
+ if (key.name === 'backspace') {
214
+ if (filterText.length > 0) {
215
+ filterText = filterText.slice(0, -1);
216
+ applyFilter();
217
+ }
218
+ redraw();
219
+ return;
220
+ }
221
+
222
+ // Arrow up
223
+ if (key.name === 'up') {
224
+ const vis = getVisibleItems();
225
+ if (vis.length > 0) {
226
+ cursor = (cursor - 1 + vis.length) % vis.length;
227
+ adjustViewport();
228
+ }
229
+ redraw();
230
+ return;
231
+ }
232
+
233
+ // Arrow down
234
+ if (key.name === 'down') {
235
+ const vis = getVisibleItems();
236
+ if (vis.length > 0) {
237
+ cursor = (cursor + 1) % vis.length;
238
+ adjustViewport();
239
+ }
240
+ redraw();
241
+ return;
242
+ }
243
+
244
+ // Printable character — append to filter
245
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
246
+ filterText += ch;
247
+ applyFilter();
248
+ redraw();
249
+ return;
250
+ }
251
+
252
+ return;
253
+ }
254
+
255
+ // --- Normal mode ---
256
+
257
+ // '/' — enter filter mode
258
+ if (ch === '/' && !key.ctrl && !key.meta) {
259
+ filterMode = true;
260
+ filterText = '';
261
+ filteredItems = filterItems(items, '');
262
+ cursor = 0;
263
+ viewOffset = 0;
264
+ redraw();
265
+ return;
266
+ }
267
+
268
+ // Escape or q
269
+ if (key.name === 'escape' || (ch === 'q' && !key.ctrl && !key.meta)) {
270
+ cleanup();
271
+ resolve(null);
272
+ return;
273
+ }
274
+
275
+ // Enter
276
+ if (key.name === 'return') {
277
+ cleanup();
278
+ stdout.write('\n');
279
+ resolve(items[cursor].value);
280
+ return;
281
+ }
282
+
283
+ // Arrow up or k
284
+ if (key.name === 'up' || ch === 'k') {
285
+ cursor = (cursor - 1 + items.length) % items.length;
286
+ adjustViewport();
287
+ redraw();
288
+ return;
289
+ }
290
+
291
+ // Arrow down or j
292
+ if (key.name === 'down' || ch === 'j') {
293
+ cursor = (cursor + 1) % items.length;
294
+ adjustViewport();
295
+ redraw();
296
+ return;
297
+ }
298
+ }
299
+
300
+ // Initial draw
301
+ stdout.write('\n');
302
+ draw();
303
+
304
+ stdin.on('keypress', onKeypress);
305
+ });
306
+ }
@@ -0,0 +1,198 @@
1
+ import { stringWidth, padEndVisible } from './box.js';
2
+ import { style } from './colors.js';
3
+ import { levelLabel, levelBorder } from './note-colors.js';
4
+
5
+ // NOTE — bordure simple
6
+ const SINGLE = { tl: '\u250c', tr: '\u2510', bl: '\u2514', br: '\u2518', h: '\u2500', v: '\u2502' };
7
+ // CR — double bordure
8
+ const DOUBLE = { tl: '\u2554', tr: '\u2557', bl: '\u255a', br: '\u255d', h: '\u2550', v: '\u2551' };
9
+
10
+ export const CARD_WIDTH = 72;
11
+
12
+ // --- NOTE card ---
13
+
14
+ export function renderNoteCard(note, options = {}) {
15
+ const level = note.level || 'L1';
16
+ const color = (ch) => levelBorder(level, ch);
17
+ const agentShort = (note.agentId || '').replace(/^Agent_/i, '');
18
+ const time = formatTime(note.timestamp);
19
+ const lbl = levelLabel(level);
20
+
21
+ const lines = [];
22
+
23
+ // Header : ┌─ L4 · DECISION · Agent · 09:03 ─────────┐
24
+ const headerText = ` ${level} \u00b7 ${lbl} \u00b7 ${agentShort} \u00b7 ${time} `;
25
+ const headerFill = SINGLE.h.repeat(Math.max(0, CARD_WIDTH - stringWidth(headerText) - 1));
26
+ lines.push(color(SINGLE.tl + SINGLE.h) + color(headerText) + color(headerFill + SINGLE.tr));
27
+
28
+ // Ligne vide
29
+ lines.push(color(SINGLE.v) + padEndVisible('', CARD_WIDTH) + color(SINGLE.v));
30
+
31
+ if (!options.compact) {
32
+ // Contenu (word-wrap a CARD_WIDTH - 4 pour padding)
33
+ const contentLines = wrapText(note.content || '', CARD_WIDTH - 4);
34
+ for (const cl of contentLines) {
35
+ lines.push(color(SINGLE.v) + ' ' + padEndVisible(cl, CARD_WIDTH - 2) + color(SINGLE.v));
36
+ }
37
+
38
+ // Actions
39
+ if (note.extractedActions?.length > 0) {
40
+ lines.push(color(SINGLE.v) + padEndVisible('', CARD_WIDTH) + color(SINGLE.v));
41
+ for (const action of note.extractedActions) {
42
+ const wrapped = wrapText(`\u21b3 ${action}`, CARD_WIDTH - 4);
43
+ for (let i = 0; i < wrapped.length; i++) {
44
+ const prefix = i === 0 ? ' ' : ' ';
45
+ lines.push(color(SINGLE.v) + padEndVisible(`${prefix}${wrapped[i]}`, CARD_WIDTH) + color(SINGLE.v));
46
+ }
47
+ }
48
+ }
49
+
50
+ // Ligne vide
51
+ lines.push(color(SINGLE.v) + padEndVisible('', CARD_WIDTH) + color(SINGLE.v));
52
+
53
+ // Footer : tags + provider + ID
54
+ const tags = (note.tags || []).map(t => `# ${t}`).join(' ');
55
+ const id = note.id || '';
56
+ const provider = note.llmProvider ? style.dim(`[${note.llmProvider}${note.llmFallback ? ' fb' : ''}]`) : '';
57
+ const footerLeft = ` ${tags}`;
58
+ const footerRight = provider ? `${provider} ${id} ` : `${id} `;
59
+ const totalNeeded = stringWidth(footerLeft) + 1 + stringWidth(footerRight);
60
+ if (totalNeeded <= CARD_WIDTH) {
61
+ const gap = CARD_WIDTH - stringWidth(footerLeft) - stringWidth(footerRight);
62
+ const footer = footerLeft + ' '.repeat(Math.max(1, gap)) + footerRight;
63
+ lines.push(color(SINGLE.v) + padEndVisible(footer, CARD_WIDTH) + color(SINGLE.v));
64
+ } else {
65
+ for (const tl of wrapText(tags, CARD_WIDTH - 4)) {
66
+ lines.push(color(SINGLE.v) + padEndVisible(` ${tl}`, CARD_WIDTH) + color(SINGLE.v));
67
+ }
68
+ lines.push(color(SINGLE.v) + padEndVisible(` ${footerRight.trim()}`, CARD_WIDTH) + color(SINGLE.v));
69
+ }
70
+ }
71
+
72
+ // Bottom border
73
+ lines.push(color(SINGLE.bl + SINGLE.h.repeat(CARD_WIDTH) + SINGLE.br));
74
+
75
+ return lines;
76
+ }
77
+
78
+ // --- CR card ---
79
+
80
+ export function renderCrCard(cr, options = {}) {
81
+ const agentShort = (cr.agentId || '').replace(/^Agent_/i, '');
82
+ const date = formatDate(cr.period?.from);
83
+ const time = formatTime(cr.period?.from);
84
+ const endTime = formatTime(cr.period?.to);
85
+
86
+ const lines = [];
87
+ const dbl = (ch) => style.nemesisAccent(ch);
88
+
89
+ // Header double : ╔═ CR · Agent · date heure ═══╗
90
+ const headerText = ` CR \u00b7 ${agentShort} \u00b7 ${date} ${time} `;
91
+ const headerFill = DOUBLE.h.repeat(Math.max(0, CARD_WIDTH - stringWidth(headerText) - 1));
92
+ lines.push(dbl(DOUBLE.tl + DOUBLE.h) + dbl(headerText) + dbl(headerFill + DOUBLE.tr));
93
+
94
+ // Ligne vide
95
+ lines.push(dbl(DOUBLE.v) + padEndVisible('', CARD_WIDTH) + dbl(DOUBLE.v));
96
+
97
+ // Titre
98
+ const titleLine = ` ${style.bold(cr.title || 'CR sans titre')}`;
99
+ lines.push(dbl(DOUBLE.v) + padEndVisible(titleLine, CARD_WIDTH) + dbl(DOUBLE.v));
100
+
101
+ // Periode + notes + trigger
102
+ const metaLine = ` Periode : ${time} \u2192 ${endTime} \u00b7 ${cr.noteCount || 0} notes \u00b7 ${cr.trigger || '-'}`;
103
+ lines.push(dbl(DOUBLE.v) + padEndVisible(metaLine, CARD_WIDTH) + dbl(DOUBLE.v));
104
+
105
+ if (!options.compact) {
106
+ lines.push(dbl(DOUBLE.v) + padEndVisible('', CARD_WIDTH) + dbl(DOUBLE.v));
107
+
108
+ // Section CONTEXTE PRECEDENT
109
+ if (cr.previousCrSummary) {
110
+ lines.push(dbl(DOUBLE.v) + padEndVisible(` ${style.bold('\u25b6 CONTEXTE PRECEDENT')}`, CARD_WIDTH) + dbl(DOUBLE.v));
111
+ for (const cl of wrapText(cr.previousCrSummary, CARD_WIDTH - 4)) {
112
+ lines.push(dbl(DOUBLE.v) + padEndVisible(` ${cl}`, CARD_WIDTH) + dbl(DOUBLE.v));
113
+ }
114
+ lines.push(dbl(DOUBLE.v) + padEndVisible('', CARD_WIDTH) + dbl(DOUBLE.v));
115
+ }
116
+
117
+ // Section DECISIONS
118
+ if (cr.decisions?.length > 0) {
119
+ lines.push(dbl(DOUBLE.v) + padEndVisible(` ${style.bold('\u25b6 DECISIONS')}`, CARD_WIDTH) + dbl(DOUBLE.v));
120
+ for (const d of cr.decisions) {
121
+ const wrapped = wrapText(`\u2022 ${d}`, CARD_WIDTH - 4);
122
+ for (let i = 0; i < wrapped.length; i++) {
123
+ const prefix = i === 0 ? ' ' : ' ';
124
+ lines.push(dbl(DOUBLE.v) + padEndVisible(`${prefix}${wrapped[i]}`, CARD_WIDTH) + dbl(DOUBLE.v));
125
+ }
126
+ }
127
+ lines.push(dbl(DOUBLE.v) + padEndVisible('', CARD_WIDTH) + dbl(DOUBLE.v));
128
+ }
129
+
130
+ // Section ACTIONS EN ATTENTE
131
+ if (cr.actions?.length > 0) {
132
+ lines.push(dbl(DOUBLE.v) + padEndVisible(` ${style.bold('\u25b6 ACTIONS EN ATTENTE')}`, CARD_WIDTH) + dbl(DOUBLE.v));
133
+ for (const a of cr.actions) {
134
+ const wrapped = wrapText(`\u2022 ${a}`, CARD_WIDTH - 4);
135
+ for (let i = 0; i < wrapped.length; i++) {
136
+ const prefix = i === 0 ? ' ' : ' ';
137
+ lines.push(dbl(DOUBLE.v) + padEndVisible(`${prefix}${wrapped[i]}`, CARD_WIDTH) + dbl(DOUBLE.v));
138
+ }
139
+ }
140
+ lines.push(dbl(DOUBLE.v) + padEndVisible('', CARD_WIDTH) + dbl(DOUBLE.v));
141
+ }
142
+
143
+ // Footer : notes liees + ID
144
+ const noteIds = (cr.noteIds || []);
145
+ let notesRef = noteIds.slice(0, 3).join(' ');
146
+ if (noteIds.length > 3) notesRef += ` ... (+${noteIds.length - 3})`;
147
+ const footerLeft = ` Notes liees : ${notesRef}`;
148
+ const crId = cr.id || '';
149
+ const crIdRight = `${crId} `;
150
+ const totalNeeded = stringWidth(footerLeft) + 1 + stringWidth(crIdRight);
151
+ if (totalNeeded <= CARD_WIDTH) {
152
+ const gap = CARD_WIDTH - stringWidth(footerLeft) - stringWidth(crIdRight);
153
+ const footer = footerLeft + ' '.repeat(Math.max(1, gap)) + crIdRight;
154
+ lines.push(dbl(DOUBLE.v) + padEndVisible(footer, CARD_WIDTH) + dbl(DOUBLE.v));
155
+ } else {
156
+ for (const fl of wrapText(`Notes liees : ${notesRef}`, CARD_WIDTH - 4)) {
157
+ lines.push(dbl(DOUBLE.v) + padEndVisible(` ${fl}`, CARD_WIDTH) + dbl(DOUBLE.v));
158
+ }
159
+ lines.push(dbl(DOUBLE.v) + padEndVisible(` ${crId}`, CARD_WIDTH) + dbl(DOUBLE.v));
160
+ }
161
+ }
162
+
163
+ // Bottom : bordure simple pour le bas (comme le mockup spec)
164
+ lines.push(SINGLE.bl + SINGLE.h.repeat(CARD_WIDTH) + SINGLE.br);
165
+
166
+ return lines;
167
+ }
168
+
169
+ // --- Utilitaires ---
170
+
171
+ export function formatTime(isoString) {
172
+ if (!isoString) return '--:--';
173
+ const d = new Date(isoString);
174
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
175
+ }
176
+
177
+ export function formatDate(isoString) {
178
+ if (!isoString) return '--/--/----';
179
+ const d = new Date(isoString);
180
+ return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
181
+ }
182
+
183
+ export function wrapText(text, maxWidth) {
184
+ if (!text) return [];
185
+ const words = text.split(/\s+/);
186
+ const lines = [];
187
+ let current = '';
188
+ for (const word of words) {
189
+ if (current && stringWidth(current + ' ' + word) > maxWidth) {
190
+ lines.push(current);
191
+ current = word;
192
+ } else {
193
+ current = current ? current + ' ' + word : word;
194
+ }
195
+ }
196
+ if (current) lines.push(current);
197
+ return lines;
198
+ }
@@ -0,0 +1,26 @@
1
+ import { style } from './colors.js';
2
+
3
+ export const LEVEL_COLORS = {
4
+ L1: { r: 34, g: 197, b: 94, label: 'INFO' },
5
+ L2: { r: 59, g: 130, b: 246, label: 'ECHANGE' },
6
+ L3: { r: 139, g: 92, b: 246, label: 'CLARIFICATION' },
7
+ L4: { r: 249, g: 115, b: 22, label: 'DECISION' },
8
+ L5: { r: 199, g: 18, b: 68, label: 'CRITIQUE' },
9
+ };
10
+
11
+ export function levelColor(level, text) {
12
+ const c = LEVEL_COLORS[level] || LEVEL_COLORS.L1;
13
+ return style.rgb(c.r, c.g, c.b, text);
14
+ }
15
+
16
+ export function levelLabel(level) {
17
+ return (LEVEL_COLORS[level] || LEVEL_COLORS.L1).label;
18
+ }
19
+
20
+ export function levelDot(level) {
21
+ return levelColor(level, '\u25CF');
22
+ }
23
+
24
+ export function levelBorder(level, char) {
25
+ return levelColor(level, char);
26
+ }