@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,287 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { detectProject } from './project.js';
5
+ import { readRegistry, writeRegistry, addLane, removeLane, getLanes, getPM } from './registry.js';
6
+ import { loadTemplate, mergeTemplate } from './templates.js';
7
+ import { generateMemory, generateOnboardingPrompt, updateClaudeMd, getRepoName } from './generators.js';
8
+
9
+ /**
10
+ * Team Add — 9 steps from spec.
11
+ */
12
+ export async function teamAdd(opts) {
13
+ const {
14
+ root = process.cwd(),
15
+ agentName,
16
+ role,
17
+ reportsTo,
18
+ profileId = null,
19
+ kairosData = null,
20
+ odmIds = [],
21
+ mcpServers = [],
22
+ environment = null,
23
+ leader = false,
24
+ } = opts;
25
+
26
+ const results = [];
27
+
28
+ // Step 1: Detect project
29
+ const project = detectProject(root);
30
+ if (!project) {
31
+ throw new Error('Aucun projet detecte. Lancez "nemesis init" d\'abord.');
32
+ }
33
+ results.push({ step: 1, label: 'Projet detecte', detail: project.id });
34
+
35
+ // Step 2: Profile Kairos
36
+ if (profileId) {
37
+ results.push({ step: 2, label: 'Profil Kairos', detail: profileId });
38
+ }
39
+
40
+ // Step 3: Agent configured
41
+ results.push({ step: 3, label: 'Agent configure', detail: `${agentName} — ${role}` });
42
+
43
+ // Step 4: Create memory.md
44
+ const agentDir = join(root, '.nemesis', 'claude', agentName);
45
+ mkdirSync(agentDir, { recursive: true });
46
+ const memoryFile = join(agentDir, 'memory.md');
47
+ if (!existsSync(memoryFile)) {
48
+ writeFileSync(memoryFile, generateMemory(agentName, role, project), 'utf-8');
49
+ }
50
+ results.push({ step: 4, label: 'memory.md cree', detail: `.nemesis/claude/${agentName}/memory.md` });
51
+
52
+ // Step 5: Create contributor card
53
+ const contribId = agentName.replace('Agent_', '').replace(/_/g, '-');
54
+ const contrib = mergeTemplate(loadTemplate('contributor', root), {
55
+ contributor_meta: { contributor_id: `CONTRIB-${contribId}`, kind: 'agent', project_id: project.id },
56
+ contributor_payload: {
57
+ identity: { display_name: agentName, role, organization: 'Arka Labs' },
58
+ expertise: { domains: [], description: role },
59
+ profiles: profileId ? [profileId] : [],
60
+ },
61
+ });
62
+ const contribFile = join(project.hcm_dir, 'contributors', `CONTRIB-${contribId}.json`);
63
+ writeFileSync(contribFile, JSON.stringify(contrib, null, 2) + '\n', 'utf-8');
64
+ results.push({ step: 5, label: 'Contributor card creee', detail: contribFile.replace(root + '/', '') });
65
+
66
+ // Step 6: Update registry
67
+ const registry = readRegistry(project.hcm_dir);
68
+ if (registry) {
69
+ addLane(registry, {
70
+ id: agentName, name: agentName, kind: 'agent', role,
71
+ reports_to: reportsTo || 'PM', reviews: [], executes_from: odmIds,
72
+ responsibilities: [role], memory_path: `.nemesis/claude/${agentName}/memory.md`,
73
+ mcp_servers: mcpServers,
74
+ leader,
75
+ });
76
+ writeRegistry(project.hcm_dir, registry);
77
+ results.push({ step: 6, label: 'Registre mis a jour', detail: `Lane ajoutee : ${agentName}` });
78
+ }
79
+
80
+ // Step 7: Update CLAUDE.md
81
+ const claudeMdPath = join(homedir(), '.claude', 'CLAUDE.md');
82
+ if (existsSync(claudeMdPath)) {
83
+ updateClaudeMd(claudeMdPath, {
84
+ agentName, repo: getRepoName(root), role,
85
+ memoryPath: `${getRepoName(root)}/.nemesis/claude/${agentName}/memory.md`,
86
+ });
87
+ results.push({ step: 7, label: 'CLAUDE.md mis a jour', detail: 'Tableau + Workflow' });
88
+ }
89
+
90
+ // Step 8: Generate onboarding prompt
91
+ const onboardingDir = join(root, '.nemesis', 'onboarding');
92
+ mkdirSync(onboardingDir, { recursive: true });
93
+ const promptFile = join(onboardingDir, `prompt-${contribId}.md`);
94
+ // Build environment state for the prompt
95
+ const envState = environment || {
96
+ hcm: !!project.hcm_status?.connected,
97
+ files: [],
98
+ };
99
+ // Add files that were just created
100
+ if (existsSync(memoryFile)) envState.files = [...(envState.files || []), `.nemesis/claude/${agentName}/memory.md`];
101
+ if (existsSync(contribFile)) envState.files = [...(envState.files || []), contribFile.replace(root + '/', '')];
102
+
103
+ writeFileSync(promptFile, generateOnboardingPrompt({ agentName, role, profileId, kairosData, reportsTo, project, odmIds, environment: envState }), 'utf-8');
104
+ results.push({ step: 8, label: 'Prompt d\'onboarding genere', detail: `.nemesis/onboarding/prompt-${contribId}.md` });
105
+
106
+ // Step 9: Launch command
107
+ const launchCmd = `claude --prompt .nemesis/onboarding/prompt-${contribId}.md`;
108
+ results.push({ step: 9, label: 'Commande de lancement', detail: launchCmd });
109
+
110
+ return { project, agentName, results, launchCmd, promptPath: promptFile };
111
+ }
112
+
113
+ /**
114
+ * Team List — build org tree from registry.
115
+ */
116
+ export function teamList(hcmDir) {
117
+ const registry = readRegistry(hcmDir);
118
+ if (!registry) return null;
119
+
120
+ const pm = getPM(registry);
121
+ const lanes = getLanes(registry);
122
+
123
+ const tree = {
124
+ label: `${pm?.name || 'PM'} (${pm?.role || 'PM'})`,
125
+ meta: pm?.kind || 'human',
126
+ children: [],
127
+ };
128
+
129
+ const byReportsTo = {};
130
+ for (const lane of lanes) {
131
+ const parent = lane.reports_to || 'PM';
132
+ if (!byReportsTo[parent]) byReportsTo[parent] = [];
133
+ byReportsTo[parent].push(lane);
134
+ }
135
+
136
+ const topAgents = [
137
+ ...(byReportsTo['PM'] || []),
138
+ ...(byReportsTo[pm?.name] || []),
139
+ ...(byReportsTo[pm?.id] || []),
140
+ ];
141
+ const seen = new Set();
142
+ for (const agent of topAgents) {
143
+ if (seen.has(agent.id)) continue;
144
+ seen.add(agent.id);
145
+ tree.children.push(buildAgentNode(agent, byReportsTo, seen));
146
+ }
147
+
148
+ for (const lane of lanes) {
149
+ if (!seen.has(lane.id)) {
150
+ tree.children.push(buildAgentNode(lane, byReportsTo, seen));
151
+ }
152
+ }
153
+
154
+ return { tree, registry };
155
+ }
156
+
157
+ function buildAgentNode(agent, byReportsTo, seen) {
158
+ const node = { label: agent.name || agent.id, meta: agent.role, children: [] };
159
+ const subordinates = byReportsTo[agent.id] || byReportsTo[agent.name] || [];
160
+ for (const sub of subordinates) {
161
+ if (seen.has(sub.id)) continue;
162
+ seen.add(sub.id);
163
+ node.children.push(buildAgentNode(sub, byReportsTo, seen));
164
+ }
165
+ return node;
166
+ }
167
+
168
+ /**
169
+ * Team Inspect — detailed view of an agent.
170
+ */
171
+ export function teamInspect(agentId, projectRoot) {
172
+ const { detectProject } = require_detect();
173
+ const project = detectProject(projectRoot);
174
+ if (!project) throw new Error('Aucun projet detecte.');
175
+
176
+ const registry = readRegistry(project.hcm_dir);
177
+ if (!registry) throw new Error('Registry introuvable.');
178
+
179
+ const lanes = getLanes(registry);
180
+ const lane = lanes.find(l => l.id === agentId || l.name === agentId);
181
+ if (!lane) throw new Error(`Agent ${agentId} non trouve dans le registre.`);
182
+
183
+ // Memory info
184
+ let memoryInfo = null;
185
+ if (lane.memory_path) {
186
+ const memPath = join(projectRoot, lane.memory_path);
187
+ if (existsSync(memPath)) {
188
+ const stat = statSync(memPath);
189
+ memoryInfo = {
190
+ path: lane.memory_path,
191
+ size: (stat.size / 1024).toFixed(1) + ' KB',
192
+ modified: stat.mtime.toISOString().split('T')[0],
193
+ };
194
+ }
195
+ }
196
+
197
+ // Assigned OdMs
198
+ const odmDir = join(project.hcm_dir, 'odm');
199
+ let assignedOdms = [];
200
+ if (existsSync(odmDir)) {
201
+ assignedOdms = readdirSync(odmDir)
202
+ .filter(f => f.endsWith('.json') && !f.startsWith('legacy_'))
203
+ .map(f => {
204
+ try {
205
+ const data = JSON.parse(readFileSync(join(odmDir, f), 'utf-8'));
206
+ return data;
207
+ } catch (_e) { /* fallback: malformed OdM */ return null; }
208
+ })
209
+ .filter(Boolean)
210
+ .filter(d => {
211
+ const assignee = d.odm_meta?.assigned_to?.actor_id || '';
212
+ return assignee === agentId || assignee === lane.name;
213
+ })
214
+ .map(d => ({
215
+ id: d.odm_meta?.odm_id || '?',
216
+ status: d.odm_meta?.status || '?',
217
+ title: d.odm_payload?.cadrage?.title || '',
218
+ }));
219
+ }
220
+
221
+ // Associated CRs
222
+ const crDir = join(project.hcm_dir, 'cr');
223
+ let crs = [];
224
+ if (existsSync(crDir)) {
225
+ crs = readdirSync(crDir)
226
+ .filter(f => f.endsWith('.json'))
227
+ .map(f => {
228
+ try { return JSON.parse(readFileSync(join(crDir, f), 'utf-8')); } catch (_e) { /* fallback: malformed CR */ return null; }
229
+ })
230
+ .filter(Boolean)
231
+ .filter(d => (d.metadata?.executed_by || '').includes(agentId))
232
+ .map(d => ({
233
+ id: d.metadata?.odm_id || '?',
234
+ title: d.title || '',
235
+ date: d.metadata?.date_execution || '',
236
+ }));
237
+ }
238
+
239
+ return {
240
+ agent: lane,
241
+ memory: memoryInfo,
242
+ odms: assignedOdms,
243
+ crs,
244
+ project: project.id,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Team Remove — archive agent (no deletion).
250
+ */
251
+ export function teamRemove(agentId, projectRoot) {
252
+ const project = detectProject(projectRoot);
253
+ if (!project) throw new Error('Aucun projet detecte.');
254
+
255
+ const registry = readRegistry(project.hcm_dir);
256
+ if (!registry) throw new Error('Registry introuvable.');
257
+
258
+ // Remove from registry
259
+ removeLane(registry, agentId);
260
+ writeRegistry(project.hcm_dir, registry);
261
+
262
+ // Remove from CLAUDE.md (if present)
263
+ const claudeMdPath = join(homedir(), '.claude', 'CLAUDE.md');
264
+ if (existsSync(claudeMdPath)) {
265
+ let content = readFileSync(claudeMdPath, 'utf-8');
266
+ // Remove table row
267
+ const lines = content.split('\n');
268
+ const filtered = lines.filter(l => !l.includes(`**${agentId}**`));
269
+ // Remove workflow line
270
+ const finalLines = filtered.filter(l => !l.includes(`${agentId} :`));
271
+ writeFileSync(claudeMdPath, finalLines.join('\n'), 'utf-8');
272
+ }
273
+
274
+ // Archive: rename memory.md → memory.md.archived (don't delete)
275
+ const memoryDir = join(projectRoot, '.nemesis', 'claude', agentId);
276
+ const memoryFile = join(memoryDir, 'memory.md');
277
+ if (existsSync(memoryFile)) {
278
+ renameSync(memoryFile, memoryFile + '.archived');
279
+ }
280
+
281
+ return { agentId, archived: true };
282
+ }
283
+
284
+ // Lazy import to avoid circular dependency
285
+ function require_detect() {
286
+ return { detectProject };
287
+ }
@@ -0,0 +1,80 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const EMBEDDED_DIR = join(__dirname, '..', '..', 'templates');
7
+
8
+ const TEMPLATE_MAP = {
9
+ 'odm': 'template_ODM-NAME-000.json',
10
+ 'cr': 'template_CR-ODM-NAME-000.exemple.json',
11
+ 'mission-contract': 'template_MISSION_CONTRACT.json',
12
+ 'registry': 'template_REGISTRY-PROJECT.json',
13
+ 'decision': 'template_DEC-NAME-000.json',
14
+ 'intervention': 'template_INTV-NAME-000.json',
15
+ 'contributor': 'template_CONTRIB-NAME.json',
16
+ 'transaction': 'template_TXN-NAME-000.json',
17
+ 'project-context': 'project-context.json',
18
+ };
19
+
20
+ /**
21
+ * Load a template by type.
22
+ * Priority: .nemesis/template/ > .owner/template/ (legacy) > embedded templates/
23
+ */
24
+ export function loadTemplate(type, projectRoot = process.cwd()) {
25
+ const filename = TEMPLATE_MAP[type];
26
+ if (!filename) {
27
+ throw new Error(`Template inconnu : ${type}. Types valides : ${Object.keys(TEMPLATE_MAP).join(', ')}`);
28
+ }
29
+
30
+ // Priority 1: .nemesis/template/
31
+ const nemesisPath = join(projectRoot, '.nemesis', 'template', filename);
32
+ if (existsSync(nemesisPath)) {
33
+ return JSON.parse(readFileSync(nemesisPath, 'utf-8'));
34
+ }
35
+
36
+ // Priority 2: .owner/template/ (legacy fallback)
37
+ const legacyPath = join(projectRoot, '.owner', 'template', filename);
38
+ if (existsSync(legacyPath)) {
39
+ return JSON.parse(readFileSync(legacyPath, 'utf-8'));
40
+ }
41
+
42
+ // Priority 3: embedded templates/
43
+ const embeddedPath = join(EMBEDDED_DIR, filename);
44
+ if (existsSync(embeddedPath)) {
45
+ return JSON.parse(readFileSync(embeddedPath, 'utf-8'));
46
+ }
47
+
48
+ throw new Error(`Template "${type}" introuvable (${filename})`);
49
+ }
50
+
51
+ /**
52
+ * Deep merge values into a template.
53
+ */
54
+ export function mergeTemplate(template, values) {
55
+ return deepMerge(structuredClone(template), values);
56
+ }
57
+
58
+ function deepMerge(target, source) {
59
+ for (const key of Object.keys(source)) {
60
+ if (
61
+ source[key] !== null &&
62
+ typeof source[key] === 'object' &&
63
+ !Array.isArray(source[key]) &&
64
+ typeof target[key] === 'object' &&
65
+ !Array.isArray(target[key])
66
+ ) {
67
+ deepMerge(target[key], source[key]);
68
+ } else {
69
+ target[key] = source[key];
70
+ }
71
+ }
72
+ return target;
73
+ }
74
+
75
+ /**
76
+ * List available template types.
77
+ */
78
+ export function listTemplateTypes() {
79
+ return Object.keys(TEMPLATE_MAP);
80
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Agent Runner — dispatch a prompt to an agent headless (non-interactive).
3
+ * The agent runs detached; its Stop hook writes the result to dispatch-results/.
4
+ * No blocking wait — the Leader polls for results via hooks.
5
+ */
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { writeFileSync, mkdirSync, existsSync, readdirSync, readFileSync, unlinkSync, openSync, closeSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { debug } from '../core/logger.js';
11
+
12
+ const PENDING_DIR = '.nemesis/kairos/pending-dispatch';
13
+ const RESULTS_DIR = '.nemesis/kairos/dispatch-results';
14
+
15
+ /**
16
+ * Launch an agent headless with a prompt. Returns immediately.
17
+ * The agent runs detached; its Stop hook will write the result.
18
+ * @param {object} agent — lane from registry (must have session_id)
19
+ * @param {string} prompt — formatted prompt text
20
+ * @param {object} opts
21
+ * @param {string} opts.txnId — transaction ID for tracking
22
+ * @param {string} [opts.odmId] — OdM ID if dispatching an OdM
23
+ * @param {string} [opts.projectId] — project ID
24
+ * @param {string} opts.root — project root
25
+ * @returns {{ pid: number|null, txnId: string }}
26
+ */
27
+ export async function launchHeadless(agent, prompt, opts = {}) {
28
+ const { txnId, odmId = '', missionId = '', projectId = '', root = process.cwd() } = opts;
29
+
30
+ if (!agent?.session_id) {
31
+ throw new Error(
32
+ `Agent ${agent?.name || 'inconnu'} n'a pas de session_id. Lancez d'abord un onboarding.`
33
+ );
34
+ }
35
+
36
+ if (!txnId) {
37
+ throw new Error('txnId requis pour le tracking du dispatch.');
38
+ }
39
+
40
+ const launchedAt = new Date().toISOString();
41
+
42
+ // Write pending dispatch file — Stop hook will read it
43
+ createPendingDispatch(root, txnId, {
44
+ agentName: agent.name || agent.id,
45
+ agentSessionId: agent.session_id,
46
+ txnId,
47
+ odmId,
48
+ missionId,
49
+ projectId,
50
+ launchedAt,
51
+ });
52
+
53
+ // Log file for diagnostics — captures stdout/stderr
54
+ const logDir = join(root, PENDING_DIR);
55
+ const logPath = join(logDir, `${txnId}.log`);
56
+ const logFd = openSync(logPath, 'w');
57
+
58
+ // Spawn detached — agent runs independently
59
+ // stdout/stderr → log file (not /dev/null) for debugging
60
+ const child = spawn('claude', [
61
+ '--resume', agent.session_id,
62
+ '-p', prompt,
63
+ '--dangerously-skip-permissions',
64
+ ], {
65
+ stdio: ['ignore', logFd, logFd],
66
+ detached: true,
67
+ env: (() => { const e = { ...process.env }; delete e.CLAUDECODE; return e; })(),
68
+ });
69
+
70
+ child.unref();
71
+ closeSync(logFd);
72
+
73
+ // Patch pending-dispatch file with PID so dashboard can track liveness
74
+ if (child.pid) {
75
+ try {
76
+ const pendingPath = join(root, PENDING_DIR, `${txnId}.json`);
77
+ const pending = JSON.parse(readFileSync(pendingPath, 'utf-8'));
78
+ pending.pid = child.pid;
79
+ writeFileSync(pendingPath, JSON.stringify(pending, null, 2) + '\n', 'utf-8');
80
+ } catch (e) { debug(`launchHeadless PID patch: ${e.message}`); }
81
+
82
+ try {
83
+ const { registerPid } = await import('./pid-checker.js');
84
+ registerPid(root, agent.name || agent.id, child.pid, agent.session_id);
85
+ } catch (e) { debug(`launchHeadless registerPid: ${e.message}`); }
86
+ }
87
+
88
+ return { pid: child.pid ?? null, txnId };
89
+ }
90
+
91
+ /**
92
+ * Create a pending dispatch marker for the Stop hook to pick up.
93
+ * @param {string} root — project root
94
+ * @param {string} txnId
95
+ * @param {object} metadata — { agentName, agentSessionId, odmId, projectId, launchedAt }
96
+ */
97
+ export function createPendingDispatch(root, txnId, metadata) {
98
+ const dir = join(root, PENDING_DIR);
99
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
100
+
101
+ const filepath = join(dir, `${txnId}.json`);
102
+ writeFileSync(filepath, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
103
+ }
104
+
105
+ /**
106
+ * Find pending dispatch for a given agent (called by Stop hook).
107
+ * @param {string} root
108
+ * @param {string} agentName
109
+ * @returns {{ txnId: string, metadata: object }|null}
110
+ */
111
+ export function findPendingDispatch(root, agentName) {
112
+ const dir = join(root, PENDING_DIR);
113
+ if (!existsSync(dir)) return null;
114
+
115
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
116
+ for (const file of files) {
117
+ try {
118
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
119
+ if (data.agentName === agentName) {
120
+ return { txnId: file.replace('.json', ''), metadata: data };
121
+ }
122
+ } catch (e) { debug(`findPendingDispatch ${file}: ${e.message}`); }
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Write dispatch result (called by Stop hook when agent finishes).
129
+ * @param {string} root
130
+ * @param {string} txnId
131
+ * @param {object} result — { agentName, odmId, response, exitCode, finishedAt }
132
+ */
133
+ export function writeDispatchResult(root, txnId, result) {
134
+ const dir = join(root, RESULTS_DIR);
135
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
136
+
137
+ const filepath = join(dir, `${txnId}.json`);
138
+ writeFileSync(filepath, JSON.stringify(result, null, 2) + '\n', 'utf-8');
139
+ }
140
+
141
+ /**
142
+ * Consume pending dispatch file (cleanup after writing result).
143
+ * @param {string} root
144
+ * @param {string} txnId
145
+ */
146
+ export function consumePendingDispatch(root, txnId) {
147
+ const filepath = join(root, PENDING_DIR, `${txnId}.json`);
148
+ try { unlinkSync(filepath); } catch (e) { debug(`consumePendingDispatch: ${e.message}`); }
149
+ }
150
+
151
+ /**
152
+ * Read and consume all dispatch results (called by Leader polling).
153
+ * @param {string} root
154
+ * @returns {Array<{ txnId: string, result: object }>}
155
+ */
156
+ export function consumeDispatchResults(root) {
157
+ const dir = join(root, RESULTS_DIR);
158
+ if (!existsSync(dir)) return [];
159
+
160
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
161
+ const results = [];
162
+
163
+ for (const file of files) {
164
+ const filepath = join(dir, file);
165
+ try {
166
+ const data = JSON.parse(readFileSync(filepath, 'utf-8'));
167
+ results.push({ txnId: file.replace('.json', ''), result: data });
168
+ unlinkSync(filepath); // consume
169
+ } catch (e) { debug(`consumeDispatchResults ${file}: ${e.message}`); }
170
+ }
171
+
172
+ return results;
173
+ }
174
+
175
+ /**
176
+ * Build a formatted dispatch prompt from an OdM or free document.
177
+ * @param {object|null} odm — full OdM JSON (null for free document)
178
+ * @param {object} opts
179
+ * @param {string} [opts.additionalContext] — extra leader instructions
180
+ * @param {string} [opts.documentBody] — free text (when odm is null)
181
+ * @returns {string}
182
+ */
183
+ export function buildDispatchPrompt(odm, opts = {}) {
184
+ const { additionalContext = '', documentBody = '' } = opts;
185
+
186
+ if (!odm) {
187
+ const parts = [
188
+ '# Document a traiter',
189
+ '',
190
+ documentBody,
191
+ '',
192
+ '## Instructions',
193
+ 'Traite ce document et depose tes livrables dans .nemesis/HCM/.',
194
+ ];
195
+ if (additionalContext) parts.push(additionalContext);
196
+ return parts.join('\n');
197
+ }
198
+
199
+ // Support both flat format (arka.odm.v1) and nested format (odm_meta/odm_payload)
200
+ const isFlat = !odm.odm_meta && (odm.odm_id || odm.title);
201
+
202
+ const odmId = isFlat ? odm.odm_id : (odm.odm_meta?.odm_id);
203
+ const title = isFlat ? odm.title : (odm.odm_payload?.cadrage?.title);
204
+ const summary = isFlat ? odm.summary : (odm.odm_payload?.cadrage?.summary);
205
+ const steps = isFlat ? (odm.steps || []) : (odm.odm_payload?.steps || []);
206
+ const deliverables = isFlat ? (odm.deliverables || []) : null;
207
+ const acceptance = isFlat ? (odm.acceptance_criteria || []) : null;
208
+ const rejection = isFlat
209
+ ? (odm.rejection_criteria || [])
210
+ : (odm.odm_payload?.rejection_criteria || '');
211
+ const objectives = isFlat ? null : (odm.odm_payload?.cadrage?.objectives);
212
+ const inScope = isFlat ? null : (odm.odm_payload?.cadrage?.in_scope);
213
+ const outScope = isFlat ? null : (odm.odm_payload?.cadrage?.out_of_scope);
214
+ const outputs = isFlat ? null : (odm.odm_payload?.outputs_expected);
215
+
216
+ const parts = [
217
+ `# Assignation OdM : ${odmId || 'N/A'}`,
218
+ '',
219
+ '## Cadrage',
220
+ `- Titre : ${title || 'N/A'}`,
221
+ ];
222
+
223
+ if (summary) parts.push(`- Resume : ${summary}`);
224
+ if (objectives) parts.push(`- Objectifs : ${Array.isArray(objectives) ? objectives.join(', ') : objectives}`);
225
+ if (inScope) parts.push(`- Scope : ${Array.isArray(inScope) ? inScope.join(', ') : inScope}`);
226
+ if (outScope) parts.push(`- Hors scope : ${Array.isArray(outScope) ? outScope.join(', ') : outScope}`);
227
+
228
+ if (steps.length > 0) {
229
+ parts.push('', '## Steps');
230
+ steps.forEach((step, i) => {
231
+ parts.push(`${i + 1}. ${step.title || ''} — ${step.description || ''}`);
232
+ });
233
+ }
234
+
235
+ if (deliverables && deliverables.length > 0) {
236
+ parts.push('', '## Livrables attendus');
237
+ deliverables.forEach(d => parts.push(`- ${d}`));
238
+ } else if (outputs?.items) {
239
+ parts.push('', '## Livrables attendus', Array.isArray(outputs.items) ? outputs.items.map(i => `- ${i}`).join('\n') : outputs.items);
240
+ }
241
+
242
+ if (acceptance && acceptance.length > 0) {
243
+ parts.push('', '## Criteres d\'acceptation');
244
+ acceptance.forEach(a => parts.push(`- ${a}`));
245
+ }
246
+
247
+ if (rejection) {
248
+ parts.push('', '## Criteres de rejet');
249
+ if (Array.isArray(rejection)) {
250
+ rejection.forEach(r => parts.push(`- ${r}`));
251
+ } else {
252
+ parts.push(rejection);
253
+ }
254
+ }
255
+
256
+ parts.push('', '## Instructions');
257
+ parts.push('Execute cet OdM. Depose ton CR dans .nemesis/HCM/cr/ a la fin.');
258
+ if (additionalContext) parts.push(additionalContext);
259
+
260
+ return parts.join('\n');
261
+ }