@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,580 @@
1
+ /**
2
+ * Orchestration dashboard + daemon — poll-based TUI that monitors AND orchestrates dispatches.
3
+ * Actively dispatches READY_TO_DISPATCH OdMs and routes results via routing.chain.
4
+ * All data from filesystem, zero network.
5
+ */
6
+
7
+ import { readdirSync, readFileSync, existsSync, statSync, unlinkSync, appendFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { emitKeypressEvents } from 'node:readline';
10
+ import { style } from './colors.js';
11
+ import { titledBox } from './box.js';
12
+ import { readRegistry, getLanes } from '../../lib/core/registry.js';
13
+ import { launchHeadless, buildDispatchPrompt } from '../kairos/agent-runner.js';
14
+ import { createDispatchTransaction } from '../kairos/dispatcher-router.js';
15
+ import { debug } from '../core/logger.js';
16
+ import { listOdm, inspectOdm, setOdmStatus, assignOdm } from '../core/odm.js';
17
+ import { pushEvent, EVENT_TYPES } from '../kairos/event-bus.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // States
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const STATES = {
24
+ IDLE: 'IDLE',
25
+ DISPATCH_ACTIVE: 'DISPATCH_ACTIVE',
26
+ MULTI_DISPATCH: 'MULTI_DISPATCH',
27
+ ESCALATION: 'ESCALATION',
28
+ DELIVERY: 'DELIVERY',
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Data loaders (filesystem)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function readJsonDir(dir) {
36
+ if (!existsSync(dir)) return [];
37
+ return readdirSync(dir)
38
+ .filter(f => f.endsWith('.json'))
39
+ .map(f => {
40
+ try {
41
+ const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
42
+ return { file: f, ...data };
43
+ } catch (e) { debug(`readJsonDir ${f}: ${e.message}`); return null; }
44
+ })
45
+ .filter(Boolean);
46
+ }
47
+
48
+ function readLogTail(logPath, lines = 5) {
49
+ if (!existsSync(logPath)) return [];
50
+ try {
51
+ const content = readFileSync(logPath, 'utf-8');
52
+ return content.split('\n').filter(Boolean).slice(-lines);
53
+ } catch (e) { debug(`readLogTail: ${e.message}`); return []; }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // detectState — determine current orchestration state
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export function detectState(root) {
61
+ const kairosDir = join(root, '.nemesis', 'kairos');
62
+ const pending = readJsonDir(join(kairosDir, 'pending-dispatch'));
63
+ const results = readJsonDir(join(kairosDir, 'dispatch-results'));
64
+ const escalations = readJsonDir(join(kairosDir, 'escalations'));
65
+ const events = readJsonDir(join(kairosDir, 'events'));
66
+
67
+ if (escalations.length > 0) {
68
+ return { state: STATES.ESCALATION, pending, results, escalations, events };
69
+ }
70
+ if (pending.length > 1) {
71
+ return { state: STATES.MULTI_DISPATCH, pending, results, escalations, events };
72
+ }
73
+ if (pending.length === 1) {
74
+ return { state: STATES.DISPATCH_ACTIVE, pending, results, escalations, events };
75
+ }
76
+ if (results.length > 0) {
77
+ return { state: STATES.DELIVERY, pending, results, escalations, events };
78
+ }
79
+ return { state: STATES.IDLE, pending, results, escalations, events };
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // checkAgentActivity — probe PID + log for liveness
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export function checkAgentActivity(dispatchEntry, root) {
87
+ const pid = dispatchEntry.pid;
88
+ let alive = false;
89
+ if (pid) {
90
+ try { process.kill(pid, 0); alive = true; } catch { alive = false; }
91
+ }
92
+
93
+ const logFile = dispatchEntry.file?.replace('.json', '.log');
94
+ const logPath = logFile
95
+ ? join(root, '.nemesis', 'kairos', 'pending-dispatch', logFile)
96
+ : null;
97
+ const logLines = logPath ? readLogTail(logPath) : [];
98
+
99
+ let status = 'dead';
100
+ if (alive) {
101
+ status = 'active';
102
+ } else if (logLines.length > 0) {
103
+ // Check if log was written recently (within 60s)
104
+ if (logPath && existsSync(logPath)) {
105
+ const stat = statSync(logPath);
106
+ const ageMs = Date.now() - stat.mtimeMs;
107
+ status = ageMs < 60_000 ? 'active' : 'silent';
108
+ }
109
+ }
110
+
111
+ return { alive, status, logLines, pid };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Relative time
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export function relativeTime(isoString) {
119
+ if (!isoString) return '';
120
+ const diff = Date.now() - new Date(isoString).getTime();
121
+ if (diff < 0) return 'maintenant';
122
+ const secs = Math.floor(diff / 1000);
123
+ if (secs < 60) return `il y a ${secs}s`;
124
+ const mins = Math.floor(secs / 60);
125
+ if (mins < 60) return `il y a ${mins}m`;
126
+ const hours = Math.floor(mins / 60);
127
+ if (hours < 24) return `il y a ${hours}h`;
128
+ const days = Math.floor(hours / 24);
129
+ return `il y a ${days}j`;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Render functions (one per state)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ function renderIdle(data, registry) {
137
+ const lanes = registry ? getLanes(registry) : [];
138
+ const lines = [
139
+ `${style.dim('Aucun dispatch en cours.')}`,
140
+ '',
141
+ `Agents enregistres : ${lanes.length}`,
142
+ ];
143
+ if (data.results.length > 0) {
144
+ lines.push(`Resultats disponibles : ${data.results.length}`);
145
+ }
146
+ lines.push('');
147
+ lines.push(style.dim('Les OdMs READY_TO_DISPATCH sont dispatches automatiquement.'));
148
+ return titledBox('Orchestration \u2014 IDLE', lines, { border: style.dim });
149
+ }
150
+
151
+ function renderDispatchActive(data, registry, root) {
152
+ const d = data.pending[0];
153
+ const activity = checkAgentActivity(d, root);
154
+
155
+ const statusIcon = activity.status === 'active'
156
+ ? style.green('\u25CF')
157
+ : activity.status === 'silent'
158
+ ? style.yellow('\u25CF')
159
+ : style.red('\u25CF');
160
+
161
+ const agentLabel = d.to || d.agent || d.agentName || '?';
162
+ const txnLabel = d.txnId || d.txn_id || d.file?.replace('.json', '') || '';
163
+ const timeLabel = d.created_at || d.dispatched_at || d.launchedAt;
164
+
165
+ const lines = [
166
+ `Agent : ${style.bold(agentLabel)} ${statusIcon} ${activity.status}`,
167
+ `OdM : ${d.odmId || d.odm_id || 'N/A'}`,
168
+ `TXN : ${style.dim(txnLabel)}`,
169
+ `PID : ${activity.pid || 'N/A'}`,
170
+ `Demarre : ${relativeTime(timeLabel)}`,
171
+ '',
172
+ ];
173
+
174
+ // OdM steps if available
175
+ if (d.steps && Array.isArray(d.steps)) {
176
+ lines.push(style.bold('Etapes :'));
177
+ for (const step of d.steps) {
178
+ const icon = step.status === 'done' ? style.green('\u2713') : '\u25CB';
179
+ lines.push(` ${icon} ${step.label || step.name || '?'}`);
180
+ }
181
+ lines.push('');
182
+ }
183
+
184
+ // Last log lines
185
+ if (activity.logLines.length > 0) {
186
+ lines.push(style.bold('Derniere activite :'));
187
+ for (const line of activity.logLines.slice(-3)) {
188
+ lines.push(` ${style.dim(line)}`);
189
+ }
190
+ }
191
+
192
+ return titledBox('Orchestration \u2014 DISPATCH ACTIF', lines, { border: style.nemesisAccent });
193
+ }
194
+
195
+ function renderMultiDispatch(data, registry, root) {
196
+ const lines = [`${data.pending.length} dispatches en cours`, ''];
197
+
198
+ for (const d of data.pending) {
199
+ const activity = checkAgentActivity(d, root);
200
+ const statusIcon = activity.status === 'active'
201
+ ? style.green('\u25CF')
202
+ : activity.status === 'silent'
203
+ ? style.yellow('\u25CF')
204
+ : style.red('\u25CF');
205
+
206
+ lines.push(`${statusIcon} ${style.bold(d.to || d.agent || d.agentName || '?')} \u2014 OdM ${d.odmId || d.odm_id || 'N/A'} ${relativeTime(d.created_at || d.dispatched_at || d.launchedAt)}`);
207
+ }
208
+
209
+ return titledBox('Orchestration \u2014 MULTI-DISPATCH', lines, { border: style.nemesisAccent });
210
+ }
211
+
212
+ function renderEscalation(data) {
213
+ const esc = data.escalations[0];
214
+ const lines = [
215
+ `${style.red('\u26A0')} ${style.bold('ESCALADE')}`,
216
+ '',
217
+ `Agent : ${style.bold(esc.from || esc.agent || '?')}`,
218
+ `Type : ${esc.type || esc.escalation_type || 'unknown'}`,
219
+ `Message : ${esc.message || esc.reason || '(pas de message)'}`,
220
+ `Date : ${relativeTime(esc.created_at || esc.timestamp)}`,
221
+ ];
222
+
223
+ if (data.escalations.length > 1) {
224
+ lines.push('');
225
+ lines.push(style.dim(`+ ${data.escalations.length - 1} autre(s) escalade(s)`));
226
+ }
227
+
228
+ return titledBox('Orchestration \u2014 ESCALADE', lines, { border: style.red });
229
+ }
230
+
231
+ function renderDelivery(data) {
232
+ const lines = [`${data.results.length} resultat(s) disponible(s)`, ''];
233
+
234
+ for (const r of data.results.slice(0, 5)) {
235
+ // Consider result successful if: explicit success flag, or response exists (agent delivered something)
236
+ const isSuccess = r.status === 'success' || r.success || (r.response && r.exitCode !== 1);
237
+ const icon = isSuccess ? style.green('\u2713') : style.red('\u2717');
238
+ const agentLabel = r.from || r.agent || r.agentName || '?';
239
+ lines.push(`${icon} ${style.bold(agentLabel)} \u2014 OdM ${r.odmId || r.odm_id || 'N/A'} ${relativeTime(r.completed_at || r.finishedAt || r.created_at)}`);
240
+ }
241
+
242
+ if (data.results.length > 5) {
243
+ lines.push(style.dim(` + ${data.results.length - 5} de plus`));
244
+ }
245
+
246
+ return titledBox('Orchestration \u2014 LIVRAISON', lines, { border: style.green });
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // renderDashboard — single frame
251
+ // ---------------------------------------------------------------------------
252
+
253
+ export function renderDashboard(root, _opts = {}) {
254
+ const hcmDir = join(root, '.nemesis', 'HCM');
255
+ let registry = null;
256
+ if (existsSync(join(hcmDir, 'project'))) {
257
+ registry = readRegistry(hcmDir);
258
+ }
259
+
260
+ const data = detectState(root);
261
+ let output;
262
+
263
+ switch (data.state) {
264
+ case STATES.ESCALATION: output = renderEscalation(data); break;
265
+ case STATES.MULTI_DISPATCH: output = renderMultiDispatch(data, registry, root); break;
266
+ case STATES.DISPATCH_ACTIVE: output = renderDispatchActive(data, registry, root); break;
267
+ case STATES.DELIVERY: output = renderDelivery(data); break;
268
+ default: output = renderIdle(data, registry); break;
269
+ }
270
+
271
+ return output;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Orchestrator daemon — dispatch + routing + Leader notifications
276
+ // ---------------------------------------------------------------------------
277
+
278
+ function orchLog(msg) {
279
+ try { appendFileSync('/tmp/nemesis-orch.log', `[${new Date().toISOString()}] ${msg}\n`); } catch {}
280
+ }
281
+
282
+ function notifyLeader(root, leader, payload) {
283
+ try {
284
+ pushEvent(root, {
285
+ target_agent_id: leader.id || leader.name,
286
+ event_type: EVENT_TYPES.DISPATCH_RESULT,
287
+ source_agent_id: 'orchestrator',
288
+ priority: 'HIGH',
289
+ payload,
290
+ });
291
+ } catch (e) { debug(`notifyLeader: ${e.message}`); }
292
+ }
293
+
294
+ /**
295
+ * One orchestration cycle: dispatch READY_TO_DISPATCH OdMs + route results via chain.
296
+ * Returns an array of action strings for display.
297
+ * @param {string} root — project root
298
+ * @returns {Promise<string[]>}
299
+ */
300
+ export async function orchestrate(root) {
301
+ const hcmDir = join(root, '.nemesis', 'HCM');
302
+ if (!existsSync(join(hcmDir, 'project'))) return [];
303
+
304
+ const registry = readRegistry(hcmDir);
305
+ if (!registry) return [];
306
+
307
+ const lanes = getLanes(registry);
308
+ const leader = lanes.find(l => l.leader);
309
+ const actions = [];
310
+
311
+ // --- S3: Auto-dispatch OdMs with READY_TO_DISPATCH ---
312
+ const odms = listOdm(hcmDir);
313
+ const readyOdms = odms.filter(o => o.status === 'READY_TO_DISPATCH');
314
+
315
+ for (const odmSummary of readyOdms) {
316
+ const assignee = odmSummary.assignee;
317
+ const agent = lanes.find(l =>
318
+ (l.name === assignee || l.id === assignee) && l.session_id
319
+ );
320
+ if (!agent) {
321
+ orchLog(`Skip ${odmSummary.id}: agent ${assignee} not found or no session`);
322
+ continue;
323
+ }
324
+
325
+ try {
326
+ setOdmStatus(odmSummary.id, 'DISPATCHING', hcmDir);
327
+
328
+ const { odm } = inspectOdm(odmSummary.id, hcmDir);
329
+ const prompt = buildDispatchPrompt(odm);
330
+ const missionId = odm.odm_meta?.mission_id || odm.odm_meta?.contract_id || '';
331
+ const projectId = odm.odm_meta?.project_id || '';
332
+
333
+ const txnResult = createDispatchTransaction({
334
+ from: 'orchestrator',
335
+ to: agent.name,
336
+ odmId: odmSummary.id,
337
+ missionId,
338
+ projectId,
339
+ root,
340
+ });
341
+
342
+ await launchHeadless(agent, prompt, {
343
+ txnId: txnResult.txnId,
344
+ odmId: odmSummary.id,
345
+ missionId,
346
+ projectId,
347
+ root,
348
+ });
349
+
350
+ setOdmStatus(odmSummary.id, 'ASSIGNED', hcmDir);
351
+
352
+ const action = `Dispatch ${odmSummary.id} → ${agent.name}`;
353
+ actions.push(action);
354
+ orchLog(action);
355
+
356
+ // S5: Notify Leader
357
+ if (leader) {
358
+ notifyLeader(root, leader, {
359
+ summary: action,
360
+ odmId: odmSummary.id,
361
+ agent_from: 'orchestrator',
362
+ agent_to: agent.name,
363
+ });
364
+ }
365
+ } catch (err) {
366
+ // Revert on error: DISPATCHING → READY_TO_DISPATCH
367
+ try { setOdmStatus(odmSummary.id, 'READY_TO_DISPATCH', hcmDir); } catch (e) { orchLog(`revert status ${odmSummary.id}: ${e.message}`); }
368
+ const action = `ERREUR ${odmSummary.id}: ${err.message}`;
369
+ actions.push(action);
370
+ orchLog(action);
371
+ }
372
+ }
373
+
374
+ // --- S4: Route dispatch results via routing.chain ---
375
+ // Read results WITHOUT consuming — only delete after successful routing
376
+ const resultsDir = join(root, '.nemesis', 'kairos', 'dispatch-results');
377
+ const resultFiles = existsSync(resultsDir)
378
+ ? readdirSync(resultsDir).filter(f => f.endsWith('.json'))
379
+ : [];
380
+
381
+ for (const file of resultFiles) {
382
+ const filepath = join(resultsDir, file);
383
+ let result;
384
+ try {
385
+ result = JSON.parse(readFileSync(filepath, 'utf-8'));
386
+ } catch (e) { debug(`orchestrate result parse ${file}: ${e.message}`); continue; }
387
+
388
+ const _txnId = file.replace('.json', '');
389
+ const odmId = result.odmId;
390
+ if (!odmId) { try { unlinkSync(filepath); } catch {} continue; }
391
+
392
+ try {
393
+ const { odm } = inspectOdm(odmId, hcmDir);
394
+ const chain = odm.odm_payload?.cadrage?.routing?.chain;
395
+ const currentAssignee = odm.odm_meta?.assigned_to?.actor_id;
396
+
397
+ if (!chain || chain.length === 0) {
398
+ // No chain → mark completed
399
+ try { setOdmStatus(odmId, 'COMPLETED', hcmDir); } catch (e) { orchLog(`setOdmStatus ${odmId}: ${e.message}`); }
400
+ const action = `Completed ${odmId} (no chain)`;
401
+ actions.push(action);
402
+ orchLog(action);
403
+
404
+ if (leader) {
405
+ notifyLeader(root, leader, {
406
+ summary: action,
407
+ odmId,
408
+ agent_from: currentAssignee || result.agentName,
409
+ agent_to: 'COMPLETED',
410
+ });
411
+ }
412
+ } else {
413
+ // Find current position in chain
414
+ const currentIdx = chain.indexOf(currentAssignee);
415
+ const nextIdx = currentIdx + 1;
416
+
417
+ if (nextIdx < chain.length) {
418
+ // Route to next agent in chain
419
+ const nextAgent = chain[nextIdx];
420
+ assignOdm(odmId, nextAgent, hcmDir);
421
+ setOdmStatus(odmId, 'READY_TO_DISPATCH', hcmDir);
422
+
423
+ const action = `Route ${odmId}: ${currentAssignee} → ${nextAgent}`;
424
+ actions.push(action);
425
+ orchLog(action);
426
+
427
+ if (leader) {
428
+ notifyLeader(root, leader, {
429
+ summary: action,
430
+ odmId,
431
+ agent_from: currentAssignee,
432
+ agent_to: nextAgent,
433
+ });
434
+ }
435
+ } else {
436
+ // End of chain → completed
437
+ try { setOdmStatus(odmId, 'COMPLETED', hcmDir); } catch (e) { orchLog(`setOdmStatus ${odmId}: ${e.message}`); }
438
+ const action = `Completed ${odmId} (chain done)`;
439
+ actions.push(action);
440
+ orchLog(action);
441
+
442
+ if (leader) {
443
+ notifyLeader(root, leader, {
444
+ summary: action,
445
+ odmId,
446
+ agent_from: currentAssignee,
447
+ agent_to: 'COMPLETED',
448
+ });
449
+ }
450
+ }
451
+ }
452
+
453
+ // Only consume AFTER successful routing
454
+ try { unlinkSync(filepath); } catch (e) { debug(`unlinkSync result ${file}: ${e.message}`); }
455
+ } catch (err) {
456
+ // DO NOT delete — result preserved for retry
457
+ orchLog(`Route error ${odmId}: ${err.message}`);
458
+ }
459
+ }
460
+
461
+ return actions;
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // startDashboard — poll loop with keypress handler + orchestrator daemon
466
+ // ---------------------------------------------------------------------------
467
+
468
+ export function startDashboard(root, opts = {}) {
469
+ const { pollInterval = 2000 } = opts;
470
+ const { stdin, stdout } = process;
471
+
472
+ let lastOutput = '';
473
+ let running = true;
474
+ let orchestrating = false;
475
+ const recentActions = [];
476
+
477
+ function render() {
478
+ const output = renderDashboard(root);
479
+ // Build full output including orchestrator actions
480
+ const actionLines = recentActions.slice(-5).map(a => ` ${style.dim('• ' + a)}`).join('\n');
481
+ const fullOutput = output + (actionLines ? '\n' + actionLines : '');
482
+
483
+ if (fullOutput === lastOutput) return;
484
+
485
+ // Clear screen and move cursor to top
486
+ stdout.write('\x1b[2J\x1b[H');
487
+ stdout.write(output);
488
+ stdout.write('\n');
489
+
490
+ // Display recent orchestrator actions
491
+ if (recentActions.length > 0) {
492
+ stdout.write(`\n ${style.bold('Orchestrateur :')}\n`);
493
+ for (const action of recentActions.slice(-5)) {
494
+ stdout.write(` ${style.dim('• ' + action)}\n`);
495
+ }
496
+ }
497
+
498
+ const data = detectState(root);
499
+ // Action bar
500
+ const actions = [];
501
+ if (data.state === STATES.DISPATCH_ACTIVE || data.state === STATES.MULTI_DISPATCH) {
502
+ actions.push('r actualiser');
503
+ }
504
+ actions.push('q quitter');
505
+ stdout.write(`\n ${style.dim(actions.join(' | '))}\n`);
506
+
507
+ lastOutput = fullOutput;
508
+ }
509
+
510
+ // Initial render
511
+ render();
512
+
513
+ // Poll interval with orchestrator
514
+ const timer = setInterval(async () => {
515
+ if (!running) return;
516
+ // Orchestrate: dispatch + route (with guard against overlapping)
517
+ if (!orchestrating) {
518
+ orchestrating = true;
519
+ try {
520
+ const actions = await orchestrate(root);
521
+ if (actions.length > 0) {
522
+ recentActions.push(...actions);
523
+ // Keep only last 20 actions
524
+ if (recentActions.length > 20) recentActions.splice(0, recentActions.length - 20);
525
+ }
526
+ } catch (err) {
527
+ orchLog(`orchestrate error: ${err.message}`);
528
+ }
529
+ orchestrating = false;
530
+ }
531
+ render();
532
+ }, pollInterval);
533
+
534
+ // Setup keypress
535
+ if (stdin.isTTY) {
536
+ emitKeypressEvents(stdin);
537
+ stdin.setRawMode(true);
538
+ stdin.resume();
539
+ stdin.setEncoding('utf8');
540
+ }
541
+
542
+ function cleanup() {
543
+ running = false;
544
+ clearInterval(timer);
545
+ if (stdin.isTTY) {
546
+ stdin.removeListener('keypress', onKey);
547
+ stdin.setRawMode(false);
548
+ stdin.pause();
549
+ }
550
+ // Restore cursor
551
+ stdout.write('\x1b[?25h');
552
+ }
553
+
554
+ function onKey(ch, key) {
555
+ if (!key) return;
556
+ if (key.ctrl && key.name === 'c') {
557
+ cleanup();
558
+ process.exit(0);
559
+ }
560
+ if (ch === 'q') {
561
+ cleanup();
562
+ return;
563
+ }
564
+ if (ch === 'r') {
565
+ lastOutput = '';
566
+ render();
567
+ }
568
+ }
569
+
570
+ if (stdin.isTTY) {
571
+ stdin.on('keypress', onKey);
572
+ }
573
+
574
+ // Cleanup on signals
575
+ const onSignal = () => { cleanup(); process.exit(0); };
576
+ process.on('SIGINT', onSignal);
577
+ process.on('SIGTERM', onSignal);
578
+
579
+ return { stop: cleanup };
580
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Error hints — structured error messages with actionable guidance.
3
+ */
4
+
5
+ import { style } from './colors.js';
6
+
7
+ export const COMMON_HINTS = {
8
+ REGISTRY_NOT_FOUND: {
9
+ hint: 'Le registre projet est introuvable.',
10
+ command: 'nemesis init',
11
+ },
12
+ AGENT_NOT_FOUND: {
13
+ hint: 'Cet agent n\'existe pas dans le registre.',
14
+ command: 'nemesis team list',
15
+ },
16
+ ODM_NOT_FOUND: {
17
+ hint: 'Aucun OdM correspondant.',
18
+ command: 'nemesis odm init',
19
+ },
20
+ HCM_UNREACHABLE: {
21
+ hint: 'Le HCM est injoignable.',
22
+ command: 'nemesis auth login',
23
+ },
24
+ NO_AGENTS: {
25
+ hint: 'Aucun agent enregistre dans le projet.',
26
+ command: 'nemesis team add',
27
+ },
28
+ AGENT_NO_SESSION: {
29
+ hint: 'Cet agent n\'a pas de session active.',
30
+ command: 'nemesis team panneau <agent>',
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Format an error with optional hint and suggested command.
36
+ * @param {string} message — main error message
37
+ * @param {{ hint?: string, command?: string }} opts
38
+ * @returns {string} formatted multi-line error string
39
+ */
40
+ export function formatError(message, opts = {}) {
41
+ const lines = [` ${style.red('\u2717')} ${message}`];
42
+ if (opts.hint) {
43
+ lines.push(` ${style.dim('\u2192')} ${opts.hint}`);
44
+ }
45
+ if (opts.command) {
46
+ lines.push(` ${style.dim('\u2192 Essayez :')} ${style.bold(opts.command)}`);
47
+ }
48
+ return lines.join('\n');
49
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Output formatter — JSON / text / yaml based on --format flag.
3
+ */
4
+
5
+ export function formatOutput(data, format = 'text') {
6
+ switch (format) {
7
+ case 'json':
8
+ return JSON.stringify(data, null, 2);
9
+ case 'yaml':
10
+ return toYaml(data);
11
+ case 'text':
12
+ default:
13
+ if (typeof data === 'string') return data;
14
+ return JSON.stringify(data, null, 2);
15
+ }
16
+ }
17
+
18
+ function toYaml(obj, indent = 0) {
19
+ const prefix = ' '.repeat(indent);
20
+ const lines = [];
21
+
22
+ if (Array.isArray(obj)) {
23
+ for (const item of obj) {
24
+ if (typeof item === 'object' && item !== null) {
25
+ lines.push(`${prefix}-`);
26
+ lines.push(toYaml(item, indent + 1));
27
+ } else {
28
+ lines.push(`${prefix}- ${formatScalar(item)}`);
29
+ }
30
+ }
31
+ } else if (typeof obj === 'object' && obj !== null) {
32
+ for (const [key, val] of Object.entries(obj)) {
33
+ if (val === null || val === undefined) {
34
+ lines.push(`${prefix}${key}: null`);
35
+ } else if (typeof val === 'object') {
36
+ lines.push(`${prefix}${key}:`);
37
+ lines.push(toYaml(val, indent + 1));
38
+ } else {
39
+ lines.push(`${prefix}${key}: ${formatScalar(val)}`);
40
+ }
41
+ }
42
+ } else {
43
+ lines.push(`${prefix}${formatScalar(obj)}`);
44
+ }
45
+
46
+ return lines.join('\n');
47
+ }
48
+
49
+ function formatScalar(val) {
50
+ if (typeof val === 'string') {
51
+ if (val.includes('\n') || val.includes(':') || val.includes('#')) {
52
+ return `"${val.replace(/"/g, '\\"')}"`;
53
+ }
54
+ return val;
55
+ }
56
+ return String(val);
57
+ }
58
+
59
+ export function print(data, format = 'text') {
60
+ console.log(formatOutput(data, format));
61
+ }