@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
@@ -0,0 +1,191 @@
1
+ import { writeFileSync, existsSync, readdirSync, readFileSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Valid OdM status transitions.
6
+ */
7
+ const VALID_TRANSITIONS = {
8
+ DRAFT: ['ASSIGNED', 'READY_TO_DISPATCH'],
9
+ ASSIGNED: ['READY_TO_DISPATCH', 'COMPLETED'],
10
+ READY_TO_DISPATCH: ['DISPATCHING'],
11
+ DISPATCHING: ['ASSIGNED', 'READY_TO_DISPATCH', 'COMPLETED'],
12
+ };
13
+
14
+ /**
15
+ * Set the status of an OdM with transition validation + atomic write.
16
+ * @param {string} id — OdM ID
17
+ * @param {string} newStatus — target status
18
+ * @param {string} hcmDir — path to .nemesis/HCM
19
+ * @returns {{ id: string, oldStatus: string, newStatus: string }}
20
+ */
21
+ export function setOdmStatus(id, newStatus, hcmDir) {
22
+ const odmDir = join(hcmDir, 'odm');
23
+ const filepath = join(odmDir, `${id}.json`);
24
+
25
+ if (!existsSync(filepath)) {
26
+ throw new Error(`OdM ${id} introuvable.`);
27
+ }
28
+
29
+ const odm = JSON.parse(readFileSync(filepath, 'utf-8'));
30
+ const oldStatus = odm.odm_meta?.status;
31
+
32
+ if (!oldStatus) {
33
+ throw new Error(`OdM ${id} n'a pas de status.`);
34
+ }
35
+
36
+ const allowed = VALID_TRANSITIONS[oldStatus];
37
+ if (!allowed || !allowed.includes(newStatus)) {
38
+ throw new Error(
39
+ `Transition invalide : ${oldStatus} → ${newStatus}. ` +
40
+ `Transitions valides depuis ${oldStatus} : ${(allowed || []).join(', ') || '(aucune)'}`
41
+ );
42
+ }
43
+
44
+ odm.odm_meta.status = newStatus;
45
+ odm.odm_meta.updated_at = new Date().toISOString();
46
+
47
+ // Atomic write: tmp + rename
48
+ const tmpPath = filepath + '.tmp';
49
+ writeFileSync(tmpPath, JSON.stringify(odm, null, 2) + '\n', 'utf-8');
50
+ renameSync(tmpPath, filepath);
51
+
52
+ return { id, oldStatus, newStatus };
53
+ }
54
+
55
+ /**
56
+ * Assign an OdM to an agent (sets assigned_to field).
57
+ * @param {string} id — OdM ID
58
+ * @param {string} agentId — agent name/id
59
+ * @param {string} hcmDir — path to .nemesis/HCM
60
+ * @returns {{ id: string, assignedTo: string }}
61
+ */
62
+ export function assignOdm(id, agentId, hcmDir) {
63
+ const odmDir = join(hcmDir, 'odm');
64
+ const filepath = join(odmDir, `${id}.json`);
65
+
66
+ if (!existsSync(filepath)) {
67
+ throw new Error(`OdM ${id} introuvable.`);
68
+ }
69
+
70
+ const odm = JSON.parse(readFileSync(filepath, 'utf-8'));
71
+ odm.odm_meta.assigned_to = { actor_type: 'agent', actor_id: agentId };
72
+ odm.odm_meta.updated_at = new Date().toISOString();
73
+
74
+ const tmpPath = filepath + '.tmp';
75
+ writeFileSync(tmpPath, JSON.stringify(odm, null, 2) + '\n', 'utf-8');
76
+ renameSync(tmpPath, filepath);
77
+
78
+ return { id, assignedTo: agentId };
79
+ }
80
+ import { loadTemplate, mergeTemplate } from './templates.js';
81
+
82
+ /**
83
+ * Init an OdM — creates a DRAFT ODM-*.json.
84
+ */
85
+ export function initOdm(id, opts = {}) {
86
+ const {
87
+ title = '', assignedTo = '', priority = 'HAUTE',
88
+ root = process.cwd(), projectId = '',
89
+ } = opts;
90
+
91
+ const hcmDir = join(root, '.nemesis', 'HCM');
92
+ const odmDir = join(hcmDir, 'odm');
93
+
94
+ if (!existsSync(odmDir)) {
95
+ throw new Error('.nemesis/HCM/odm/ introuvable. Lancez "nemesis init" d\'abord.');
96
+ }
97
+
98
+ const template = loadTemplate('odm', root);
99
+ const odm = mergeTemplate(template, {
100
+ odm_meta: {
101
+ odm_id: id,
102
+ project_id: projectId,
103
+ status: 'DRAFT',
104
+ priority,
105
+ created_at: new Date().toISOString(),
106
+ created_by: { actor_type: 'human', actor_id: 'PM' },
107
+ assigned_to: assignedTo ? { actor_type: 'agent', actor_id: assignedTo } : { actor_type: '', actor_id: '' },
108
+ },
109
+ odm_payload: {
110
+ cadrage: { title },
111
+ },
112
+ });
113
+
114
+ const filepath = join(odmDir, `${id}.json`);
115
+ if (existsSync(filepath)) {
116
+ throw new Error(`OdM ${id} existe deja.`);
117
+ }
118
+
119
+ writeFileSync(filepath, JSON.stringify(odm, null, 2) + '\n', 'utf-8');
120
+ return { filepath, id, status: 'DRAFT' };
121
+ }
122
+
123
+ /**
124
+ * List all OdMs — ignores legacy_* files.
125
+ */
126
+ export function listOdm(hcmDir) {
127
+ const odmDir = join(hcmDir, 'odm');
128
+ if (!existsSync(odmDir)) return [];
129
+
130
+ return readdirSync(odmDir)
131
+ .filter(f => f.endsWith('.json') && !f.startsWith('legacy_'))
132
+ .map(f => {
133
+ try {
134
+ const data = JSON.parse(readFileSync(join(odmDir, f), 'utf-8'));
135
+ return {
136
+ id: data.odm_meta?.odm_id || f.replace('.json', ''),
137
+ title: data.odm_payload?.cadrage?.title || '',
138
+ assignee: data.odm_meta?.assigned_to?.actor_id || '',
139
+ priority: data.odm_meta?.priority || '',
140
+ status: data.odm_meta?.status || 'UNKNOWN',
141
+ };
142
+ } catch (_e) {
143
+ /* fallback: malformed OdM */
144
+ return { id: f.replace('.json', ''), title: '', assignee: '', priority: '', status: 'ERROR' };
145
+ }
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Inspect a specific OdM — full content + associated CRs.
151
+ */
152
+ export function inspectOdm(id, hcmDir) {
153
+ const odmDir = join(hcmDir, 'odm');
154
+ const filepath = join(odmDir, `${id}.json`);
155
+
156
+ if (!existsSync(filepath)) {
157
+ throw new Error(`OdM ${id} introuvable.`);
158
+ }
159
+
160
+ const odm = JSON.parse(readFileSync(filepath, 'utf-8'));
161
+
162
+ // Find associated CRs
163
+ const crDir = join(hcmDir, 'cr');
164
+ let associatedCrs = [];
165
+ if (existsSync(crDir)) {
166
+ associatedCrs = readdirSync(crDir)
167
+ .filter(f => f.endsWith('.json'))
168
+ .map(f => {
169
+ try {
170
+ const data = JSON.parse(readFileSync(join(crDir, f), 'utf-8'));
171
+ return { file: f, data };
172
+ } catch (_e) {
173
+ /* fallback: malformed CR */
174
+ return null;
175
+ }
176
+ })
177
+ .filter(Boolean)
178
+ .filter(cr => {
179
+ const odmRef = cr.data.metadata?.odm_id || '';
180
+ return odmRef === id || cr.file.includes(id.replace('ODM-', ''));
181
+ })
182
+ .map(cr => ({
183
+ id: cr.data.metadata?.odm_id || cr.file,
184
+ title: cr.data.title || '',
185
+ status: cr.data.metadata?.status || 'UNKNOWN',
186
+ date: cr.data.metadata?.date_execution || '',
187
+ }));
188
+ }
189
+
190
+ return { odm, associatedCrs };
191
+ }
@@ -0,0 +1,323 @@
1
+ import { interactiveMenu } from '../ui/menu.js';
2
+ import { titledBox } from '../ui/box.js';
3
+ import { style } from '../ui/colors.js';
4
+ import { askText, askConfirm } from '../ui/prompt.js';
5
+ import { createSpinner } from '../ui/spinner.js';
6
+ import { debug } from './logger.js';
7
+
8
+ /**
9
+ * Search profiles by sector via interactive picker.
10
+ * Returns selected profileId or null if cancelled.
11
+ * @param {object} client
12
+ * @param {function} write — UI output callback
13
+ */
14
+ async function searchBySector(client, write) {
15
+ const spinner = createSpinner('Chargement des secteurs...');
16
+ spinner.start();
17
+
18
+ let profiles;
19
+ try {
20
+ // q is required by the API — use 'a' to match all profiles
21
+ const all = await client.searchProfiles('a');
22
+ profiles = Array.isArray(all) ? all : [];
23
+ } catch (e) {
24
+ debug(`searchBySector: ${e.message}`);
25
+ spinner.fail('Recherche indisponible');
26
+ return null;
27
+ }
28
+
29
+ const sectorSet = new Set();
30
+ for (const p of profiles) {
31
+ const sectors = Array.isArray(p.sectors) ? p.sectors : [];
32
+ for (const s of sectors) {
33
+ if (s) sectorSet.add(s);
34
+ }
35
+ }
36
+ const sectors = [...sectorSet].sort((a, b) => a.localeCompare(b, 'fr'));
37
+ spinner.stop(`${sectors.length} secteurs`);
38
+
39
+ if (sectors.length === 0) {
40
+ write(` ${style.yellow('\u26A0')} Aucun secteur disponible.\n`);
41
+ return null;
42
+ }
43
+
44
+ const sector = await interactiveMenu(
45
+ sectors.map(s => ({ label: s, value: s })),
46
+ { title: 'Filtrer par secteur', maxVisible: 15 },
47
+ );
48
+ if (!sector) return null;
49
+
50
+ const sp2 = createSpinner(`Recherche secteur : ${sector}...`);
51
+ sp2.start();
52
+
53
+ try {
54
+ const results = await client.searchProfiles(null, sector);
55
+ const filtered = Array.isArray(results) ? results : [];
56
+ sp2.stop(`${filtered.length} profils`);
57
+
58
+ if (filtered.length === 0) {
59
+ write(` Aucun profil pour le secteur "${sector}".\n`);
60
+ return null;
61
+ }
62
+
63
+ const items = filtered.map(p => ({
64
+ label: p.id,
65
+ value: p.id,
66
+ description: p.role || '',
67
+ }));
68
+
69
+ return interactiveMenu(items, {
70
+ title: `Profils — ${sector}`,
71
+ maxVisible: 10,
72
+ });
73
+ } catch (e) {
74
+ debug(`searchBySector filter: ${e.message}`);
75
+ sp2.fail('Erreur recherche');
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Fetch profile list from HCM, enriched with role from NUCLEUS.
82
+ * Priority: /profiles/summary (1 request, id+role pre-joined).
83
+ * Fallback: listProfiles + fetchAllRoles (searchAtoms batch or per-profile).
84
+ * Returns sorted array of { id, role } or empty array.
85
+ */
86
+ export async function fetchProfileList(client) {
87
+ // Fast path: /profiles/summary returns [{ id, role }] in 1 request
88
+ if (typeof client.listProfilesSummary === 'function') {
89
+ try {
90
+ const summary = await client.listProfilesSummary();
91
+ if (Array.isArray(summary) && summary.length > 0) {
92
+ return summary
93
+ .map(p => ({ id: p.id || '', role: p.role || '' }))
94
+ .filter(p => p.id)
95
+ .sort((a, b) => a.id.localeCompare(b.id, 'fr'));
96
+ }
97
+ } catch (e) {
98
+ debug(`fetchProfileList summary: ${e.message}`);
99
+ }
100
+ }
101
+
102
+ // Fallback: listProfiles + enrich with roles
103
+ const profiles = await client.listProfiles();
104
+ if (!Array.isArray(profiles) || profiles.length === 0) return [];
105
+
106
+ const list = profiles
107
+ .map(p => ({ id: p.label || p.profileId || p.id, role: '' }))
108
+ .filter(p => p.id)
109
+ .sort((a, b) => a.id.localeCompare(b.id, 'fr'));
110
+
111
+ try {
112
+ const roleMap = await fetchAllRoles(client);
113
+ for (const item of list) {
114
+ if (roleMap[item.id]) {
115
+ item.role = roleMap[item.id];
116
+ }
117
+ }
118
+ } catch (e) {
119
+ debug(`fetchProfileList roles: ${e.message}`);
120
+ }
121
+
122
+ return list;
123
+ }
124
+
125
+ /**
126
+ * Fetch all NUCLEUS role atoms in one call, return { profileId → shortRole } map.
127
+ */
128
+ async function fetchAllRoles(client) {
129
+ const map = {};
130
+
131
+ // Try batch searchAtoms first (1 call)
132
+ if (typeof client.searchAtoms === 'function') {
133
+ try {
134
+ const atoms = await client.searchAtoms({ sousFamille: 'NUCLEUS', type: 'role', limit: 200 });
135
+ for (const a of atoms) {
136
+ const profileId = a.famille || a.profileId || '';
137
+ const occ = a.occurrence || a.node?.data?.occurrence || a.data?.occurrence || '';
138
+ if (profileId && occ) {
139
+ map[profileId] = occ.split(' \u2014 ')[0] || occ;
140
+ }
141
+ }
142
+ if (Object.keys(map).length > 0) return map;
143
+ } catch (e) {
144
+ debug(`fetchAllRoles searchAtoms: ${e.message}`);
145
+ }
146
+ }
147
+
148
+ // Fallback: per-profile fetch, 5 at a time max
149
+ if (typeof client.getProfileAtoms === 'function') {
150
+ try {
151
+ const profiles = await client.listProfiles();
152
+ const ids = (Array.isArray(profiles) ? profiles : [])
153
+ .map(p => p.label || p.profileId || p.id)
154
+ .filter(Boolean);
155
+
156
+ const BATCH = 5;
157
+ for (let i = 0; i < ids.length; i += BATCH) {
158
+ const batch = ids.slice(i, i + BATCH);
159
+ const results = await Promise.allSettled(
160
+ batch.map(id => client.getProfileAtoms(id, ['NUCLEUS']).catch(() => []))
161
+ );
162
+ for (let j = 0; j < batch.length; j++) {
163
+ if (results[j].status === 'fulfilled') {
164
+ const atoms = results[j].value;
165
+ const items = Array.isArray(atoms) ? atoms : (atoms?.atoms || []);
166
+ const nucleus = extractNucleus(items);
167
+ if (nucleus.role) {
168
+ map[batch[j]] = nucleus.role.split(' \u2014 ')[0] || nucleus.role;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ } catch (e) {
174
+ debug(`fetchAllRoles per-profile: ${e.message}`);
175
+ }
176
+ }
177
+
178
+ return map;
179
+ }
180
+
181
+ /**
182
+ * Fetch NUCLEUS atoms for a single profile (role, mission, mindset).
183
+ * Returns { role, mission, mindset } or null.
184
+ */
185
+ export async function fetchProfileDetail(client, profileId) {
186
+ try {
187
+ const atoms = await client.getProfileAtoms(profileId, ['NUCLEUS']);
188
+ const items = Array.isArray(atoms) ? atoms : (atoms?.atoms || []);
189
+ return extractNucleus(items);
190
+ } catch (e) {
191
+ debug(`fetchProfileDetail ${profileId}: ${e.message}`);
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Extract role/mission/mindset from NUCLEUS atoms array.
198
+ */
199
+ export function extractNucleus(atoms) {
200
+ const get = (label) => {
201
+ const atom = atoms.find(a =>
202
+ (a.node?.label || a.label || '').toLowerCase() === label
203
+ );
204
+ return atom?.node?.data?.occurrence || atom?.data?.occurrence || '';
205
+ };
206
+ return {
207
+ role: get('role'),
208
+ mission: get('mission'),
209
+ mindset: get('mindset'),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Render a profile confirmation box using titledBox.
215
+ * Returns the formatted string.
216
+ */
217
+ export function renderProfileConfirmation(profileId, detail) {
218
+ const lines = [];
219
+ if (detail.role) {
220
+ lines.push(`${style.bold('Role')} ${detail.role}`);
221
+ }
222
+ if (detail.mission) {
223
+ lines.push('');
224
+ lines.push(`${style.bold('Mission')} ${detail.mission}`);
225
+ }
226
+ if (detail.mindset) {
227
+ lines.push('');
228
+ lines.push(`${style.bold('Mindset')} ${style.dim(detail.mindset)}`);
229
+ }
230
+ return titledBox(`Profil : ${profileId}`, lines, {
231
+ border: style.nemesisAccent,
232
+ padding: 1,
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Full interactive profile picker: list → select → confirm → return profileId.
238
+ * @param {object} client — HCM client
239
+ * @param {{ write?: function }} [opts] — write callback for UI output (default: no-op)
240
+ * @returns {Promise<{ id: string, kairosData: object|null }>}
241
+ */
242
+ export async function pickProfile(client, { write = () => {} } = {}) {
243
+ let profileItems = [{ label: style.dim('(saisie manuelle)'), value: '__custom__' }];
244
+ let hcmConnected = false;
245
+
246
+ const spinner = createSpinner('Chargement des profils Kairos...');
247
+ spinner.start();
248
+
249
+ try {
250
+ const profiles = await fetchProfileList(client);
251
+ hcmConnected = true;
252
+ spinner.stop(`${profiles.length} profils charges`);
253
+ if (profiles.length > 0) {
254
+ profileItems = [
255
+ { label: style.nemesisAccent('(rechercher par secteur)'), value: '__search__' },
256
+ ...profiles.map(p => ({
257
+ label: p.id,
258
+ value: p.id,
259
+ description: p.role || '',
260
+ })),
261
+ { label: style.dim('(saisie manuelle)'), value: '__custom__' },
262
+ ];
263
+ } else {
264
+ write(` ${style.yellow('\u26A0')} HCM connecte mais aucun profil Kairos disponible.\n`);
265
+ }
266
+ } catch (err) {
267
+ spinner.fail('HCM non connecte');
268
+ write(`\n ${style.yellow('\u26A0')} HCM non connecte \u2014 saisie manuelle du profil.`);
269
+ write(` ${style.dim('\u2192')} ${style.dim(err.message || String(err))}`);
270
+ write(` ${style.dim('\u2192 Configurez avec')} nemesis auth login\n`);
271
+ }
272
+
273
+ // Selection loop — allows retry on confirmation decline
274
+ while (true) {
275
+ const profileCount = profileItems.filter(p => p.value !== '__custom__').length;
276
+ const titleSuffix = hcmConnected && profileCount > 0 ? ` (${profileCount} profils HCM)` : '';
277
+ const selected = await interactiveMenu(profileItems, {
278
+ title: `Profil Kairos${titleSuffix}`,
279
+ maxVisible: 10,
280
+ });
281
+
282
+ if (selected === '__search__') {
283
+ const found = await searchBySector(client, write);
284
+ if (found) {
285
+ const spinner2 = createSpinner('Chargement du profil...');
286
+ spinner2.start();
287
+ const detail = await fetchProfileDetail(client, found);
288
+ spinner2.stop(found);
289
+ if (detail && (detail.role || detail.mission)) {
290
+ write('');
291
+ write(renderProfileConfirmation(found, detail));
292
+ const confirmed = await askConfirm('Confirmer ce profil ?', true);
293
+ if (confirmed) return { id: found, kairosData: detail };
294
+ } else {
295
+ return { id: found, kairosData: null };
296
+ }
297
+ }
298
+ continue;
299
+ }
300
+
301
+ if (selected === '__custom__' || selected === null) {
302
+ const customId = await askText('Profil Kairos ID (optionnel)', '');
303
+ return { id: customId, kairosData: null };
304
+ }
305
+
306
+ // Confirmation screen — fetch NUCLEUS for the selected profile only
307
+ if (hcmConnected) {
308
+ const spinner3 = createSpinner('Chargement du profil...');
309
+ spinner3.start();
310
+ const detail = await fetchProfileDetail(client, selected);
311
+ spinner3.stop(selected);
312
+ if (detail && (detail.role || detail.mission)) {
313
+ write('');
314
+ write(renderProfileConfirmation(selected, detail));
315
+ const confirmed = await askConfirm('Confirmer ce profil ?', true);
316
+ if (confirmed) return { id: selected, kairosData: detail };
317
+ continue;
318
+ }
319
+ }
320
+
321
+ return { id: selected, kairosData: null };
322
+ }
323
+ }