@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,131 @@
1
+ import { pushEvent } from './event-bus.js';
2
+ import { verifyAgentAlive } from './pid-checker.js';
3
+ import { readRegistry, getLanes } from '../core/registry.js';
4
+ import { detectProject } from '../core/project.js';
5
+ import { debug } from '../core/logger.js';
6
+
7
+ /**
8
+ * Route an event to the target agent using PID-aware decision tree.
9
+ *
10
+ * Decision tree:
11
+ * 1. Agent in registry? No → failed
12
+ * 2. verifyAgentAlive(lane) → alive? Yes → pushEvent() (method: 'queued')
13
+ * 3. Agent dead + session_id → launchHeadless() with event prompt (method: 'direct')
14
+ * 4. No session_id → failed
15
+ *
16
+ * @param {string} projectRoot
17
+ * @param {object} event - { target_agent_id, event_type, payload, priority, ttl, source_agent_id }
18
+ * @returns {{ delivered: boolean, method: 'queued'|'direct'|'failed', detail: string }}
19
+ */
20
+ export async function routeEvent(projectRoot, event) {
21
+ const project = detectProject(projectRoot);
22
+ if (!project) return { delivered: false, method: 'failed', detail: 'no_project' };
23
+
24
+ const reg = readRegistry(project.hcm_dir);
25
+ if (!reg) return { delivered: false, method: 'failed', detail: 'no_registry' };
26
+
27
+ const lanes = getLanes(reg);
28
+ const lane = lanes.find(l =>
29
+ l.id === event.target_agent_id || l.name === event.target_agent_id
30
+ );
31
+
32
+ // Step 1: Agent in registry?
33
+ if (!lane) {
34
+ return { delivered: false, method: 'failed', detail: `agent_not_found: ${event.target_agent_id}` };
35
+ }
36
+
37
+ // Step 2: Agent alive? → queue event
38
+ const { alive } = verifyAgentAlive(lane);
39
+ if (alive) {
40
+ pushEvent(projectRoot, event);
41
+ return { delivered: true, method: 'queued', detail: `queued for PID ${lane.pid}` };
42
+ }
43
+
44
+ // Step 3: Agent dead but has session_id → launch headless with event prompt
45
+ if (lane.session_id) {
46
+ try {
47
+ const { launchHeadless } = await import('./agent-runner.js');
48
+ const prompt = buildEventPrompt(event);
49
+ const txnId = `EVT-ROUTE-${Date.now()}`;
50
+ const { pid } = await launchHeadless(
51
+ { name: lane.name || lane.id, session_id: lane.session_id },
52
+ prompt,
53
+ { txnId, root: projectRoot },
54
+ );
55
+ return { delivered: true, method: 'direct', detail: `launched PID ${pid}` };
56
+ } catch (err) {
57
+ // Fallback: queue it anyway — agent may resume later
58
+ pushEvent(projectRoot, event);
59
+ return { delivered: true, method: 'queued', detail: `direct_failed: ${err.message}, queued as fallback` };
60
+ }
61
+ }
62
+
63
+ // Step 4: No session_id
64
+ return { delivered: false, method: 'failed', detail: 'no_session_id' };
65
+ }
66
+
67
+ /**
68
+ * Deliver an event directly to an alive agent by launching a headless prompt.
69
+ * Only called from CLI (nemesis kairos push), NOT from hooks (timeout < 5s).
70
+ *
71
+ * @param {string} projectRoot
72
+ * @param {object} lane - Registry lane
73
+ * @param {object} event - Event object
74
+ * @returns {{ delivered: boolean, pid: number|null }}
75
+ */
76
+ export async function deliverDirect(projectRoot, lane, event) {
77
+ if (!lane.session_id) {
78
+ return { delivered: false, pid: null };
79
+ }
80
+
81
+ try {
82
+ const { launchHeadless } = await import('./agent-runner.js');
83
+ const prompt = buildEventPrompt(event);
84
+ const txnId = `EVT-DIRECT-${Date.now()}`;
85
+ const { pid } = await launchHeadless(
86
+ { name: lane.name || lane.id, session_id: lane.session_id },
87
+ prompt,
88
+ { txnId, root: projectRoot },
89
+ );
90
+ return { delivered: true, pid };
91
+ } catch (e) {
92
+ debug(`deliverDirect: ${e.message}`);
93
+ return { delivered: false, pid: null };
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Build a prompt string from an event for headless injection.
99
+ * @param {object} event
100
+ * @returns {string}
101
+ */
102
+ export function buildEventPrompt(event) {
103
+ const lines = [
104
+ `# Evenement Kairos : ${event.event_type}`,
105
+ '',
106
+ `Priorite: ${event.priority || 'MEDIUM'}`,
107
+ `De: ${event.source_agent_id || 'system'}`,
108
+ `Date: ${event.created_at || new Date().toISOString()}`,
109
+ ];
110
+
111
+ if (event.payload) {
112
+ lines.push('', '## Payload');
113
+ if (event.payload.summary) lines.push(`Resume: ${event.payload.summary}`);
114
+ if (event.payload.odmId) lines.push(`OdM: ${event.payload.odmId}`);
115
+ if (event.payload.cr_id) lines.push(`CR: ${event.payload.cr_id}`);
116
+ if (event.payload.message) lines.push(`Message: ${event.payload.message}`);
117
+
118
+ // Dump remaining payload keys
119
+ const shown = new Set(['summary', 'odmId', 'cr_id', 'message']);
120
+ for (const [key, val] of Object.entries(event.payload)) {
121
+ if (!shown.has(key) && val !== undefined && val !== null) {
122
+ lines.push(`${key}: ${typeof val === 'object' ? JSON.stringify(val) : val}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ lines.push('', '## Action requise');
128
+ lines.push('Traite cet evenement selon son type et sa priorite.');
129
+
130
+ return lines.join('\n');
131
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Flowmap Bridge — map dispatch results to XState flowmap events.
3
+ * Connects the dispatcher classification to the mission state machine.
4
+ */
5
+
6
+ import { debug } from '../core/logger.js';
7
+
8
+ /**
9
+ * Signal patterns for automatic flowmap event detection.
10
+ * Each pattern has keywords and the flowmap event to fire.
11
+ */
12
+ const SIGNAL_PATTERNS = [
13
+ {
14
+ id: 'deliver_odm',
15
+ keywords: ['cr depose', 'cr deposé', 'cr-odm', 'compte-rendu', 'livrable depose', 'livrable deposé'],
16
+ event: { type: 'DELIVER_ODM' },
17
+ },
18
+ {
19
+ id: 'complete_implementation',
20
+ keywords: ['implementation complete', 'implémentation complète', 'implementation terminee', 'tache terminee', 'tâche terminée', 'implementation done'],
21
+ event: { type: 'COMPLETE_IMPLEMENTATION' },
22
+ },
23
+ {
24
+ id: 'qa_fail',
25
+ keywords: ['rejet', 'rejete', 'non conforme', 'qa fail', 'qa ko', 'renvoi', 'non valide'],
26
+ requiresLeader: true,
27
+ event: { type: 'QA_VERDICT', verdict: 'FAIL' },
28
+ },
29
+ {
30
+ id: 'qa_pass',
31
+ keywords: ['valide', 'validation ok', 'qa ok', 'qa pass', 'conforme'],
32
+ requiresLeader: true,
33
+ event: { type: 'QA_VERDICT', verdict: 'PASS' },
34
+ },
35
+ ];
36
+
37
+ /**
38
+ * Process a dispatch result and determine if a flowmap event should fire.
39
+ *
40
+ * @param {string} projectRoot
41
+ * @param {object} result - dispatch result { agentName, odmId, response, ... }
42
+ * @param {object} classification - routing entry { target, reason, summary, priority }
43
+ * @returns {{ event: object, signalId: string, missionId: string|null } | null}
44
+ */
45
+ export function processDispatchResult(projectRoot, result, classification) {
46
+ if (!result || !classification) return null;
47
+ if (classification.target === 'NOISE') return null;
48
+
49
+ const text = [
50
+ result.response || '',
51
+ classification.reason || '',
52
+ classification.summary || '',
53
+ ].join(' ').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
54
+
55
+ const isLeader = classification.target === 'LEADER';
56
+
57
+ for (const pattern of SIGNAL_PATTERNS) {
58
+ if (pattern.requiresLeader && !isLeader) continue;
59
+
60
+ const matched = pattern.keywords.some(kw => {
61
+ const normalizedKw = kw.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
62
+ return text.includes(normalizedKw);
63
+ });
64
+
65
+ if (matched) {
66
+ return {
67
+ event: { ...pattern.event },
68
+ signalId: pattern.id,
69
+ missionId: result.missionId || null,
70
+ };
71
+ }
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Send a detected flowmap event to the mission state machine.
79
+ * Graceful: returns null if no mission or flowmap error.
80
+ *
81
+ * @param {string} projectRoot
82
+ * @param {string} missionId
83
+ * @param {object} event - XState event { type, ... }
84
+ * @returns {Promise<{ value: string, context: object } | null>}
85
+ */
86
+ export async function fireFlowmapEvent(projectRoot, missionId, event) {
87
+ if (!missionId) return null;
88
+
89
+ try {
90
+ const { sendFlowmapEvent } = await import('../core/flowmap/api.js');
91
+ return sendFlowmapEvent(missionId, event, { root: projectRoot });
92
+ } catch (e) {
93
+ debug(`fireFlowmapEvent: ${e.message}`);
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Full pipeline: analyze dispatch result, detect signal, fire flowmap event.
100
+ *
101
+ * @param {string} projectRoot
102
+ * @param {object} result - dispatch result
103
+ * @param {object} classification - routing entry
104
+ * @returns {Promise<{ event: object, signalId: string, newState: string|null } | null>}
105
+ */
106
+ export async function bridgeDispatchToFlowmap(projectRoot, result, classification) {
107
+ const detected = processDispatchResult(projectRoot, result, classification);
108
+ if (!detected) return null;
109
+
110
+ const missionId = detected.missionId || result.missionId || result.odmId;
111
+ if (!missionId) return { ...detected, newState: null };
112
+
113
+ const state = await fireFlowmapEvent(projectRoot, missionId, detected.event);
114
+
115
+ return {
116
+ event: detected.event,
117
+ signalId: detected.signalId,
118
+ newState: state?.value || null,
119
+ };
120
+ }
@@ -0,0 +1,351 @@
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { openTurn, closeTurn } from '../core/notewriter/log.js';
4
+ import { generateNote } from '../core/notewriter/notes.js';
5
+ import { shouldGenerateCR, generateCR, consumePendingCr } from '../core/notewriter/cr.js';
6
+ import { readNotewriterConfig } from '../core/notewriter/config.js';
7
+ import { findPendingDispatch, writeDispatchResult, consumePendingDispatch } from './agent-runner.js';
8
+ import { resolveAgentContext } from './context-loader.js';
9
+ import { readCurrentSituation, writeCurrentSituation } from './context-writer.js';
10
+ import { detectSituation } from './situation-detector.js';
11
+ import { routeToFile } from './dispatcher-router.js';
12
+ import { processDispatchResult, fireFlowmapEvent } from './flowmap-bridge.js';
13
+ import { buildUnifiedContext, buildSituationUpdate } from './context-injector.js';
14
+
15
+ function debugLog(hook, msg) {
16
+ try { appendFileSync('/tmp/kairos-debug.log', `[${new Date().toISOString()}] ${hook}: ${msg}\n`); } catch {}
17
+ }
18
+
19
+ export function readStdin() {
20
+ try {
21
+ const data = readFileSync(0, 'utf-8'); // fd 0 = stdin
22
+ if (!data.trim()) return {};
23
+ return JSON.parse(data);
24
+ } catch (e) {
25
+ debugLog('readStdin', `parse failed: ${e.message}`);
26
+ return {};
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Extract the last assistant response from a Claude Code transcript JSONL.
32
+ * Transcript entries with type "assistant" have message.content[] with text blocks.
33
+ */
34
+ export function extractLastResponse(transcriptPath) {
35
+ if (!transcriptPath) return '';
36
+ try {
37
+ const raw = readFileSync(transcriptPath, 'utf-8');
38
+ const lines = raw.trim().split('\n');
39
+ let lastAssistant = null;
40
+ for (const line of lines) {
41
+ if (!line.trim()) continue;
42
+ try {
43
+ const entry = JSON.parse(line);
44
+ if (entry.type === 'assistant') lastAssistant = entry;
45
+ } catch (e) { debugLog('extractLastResponse', `skip: ${e.message}`); }
46
+ }
47
+ if (!lastAssistant?.message?.content) return '';
48
+ const textBlocks = lastAssistant.message.content
49
+ .filter(b => b.type === 'text')
50
+ .map(b => b.text || '');
51
+ return textBlocks.join('\n').trim();
52
+ } catch (e) {
53
+ debugLog('extractLastResponse', `failed: ${e.message}`);
54
+ return '';
55
+ }
56
+ }
57
+
58
+ export async function detectAgent(projectRoot, sessionId) {
59
+ try {
60
+ const { readRegistry, getLanes } = await import('../core/registry.js');
61
+ const { detectProject } = await import('../core/project.js');
62
+ const project = detectProject(projectRoot);
63
+ debugLog('detectAgent', `root=${projectRoot} project=${project?.id || 'null'} hcm_dir=${project?.hcm_dir || 'null'}`);
64
+ if (!project) return null;
65
+ const reg = readRegistry(project.hcm_dir);
66
+ debugLog('detectAgent', `reg=${reg ? 'found' : 'null'}`);
67
+ if (!reg) return null;
68
+ const lanes = getLanes(reg);
69
+ debugLog('detectAgent', `lanes=${lanes?.length || 0} sessionId=${sessionId}`);
70
+ if (!lanes || lanes.length === 0) return null;
71
+ // Match by session_id when available (multi-agent safe)
72
+ if (sessionId) {
73
+ const match = lanes.find(l => l.session_id === sessionId);
74
+ if (match) return match.name || null;
75
+ // No match — unknown session (service LLM, etc.), do not fallback
76
+ return null;
77
+ }
78
+ // No sessionId provided — fallback to first agent (legacy)
79
+ const active = lanes.find(l => l.session_id);
80
+ return active?.name || lanes[0]?.name || null;
81
+ } catch (e) { debugLog('detectAgent', `failed: ${e.message}`); return null; }
82
+ }
83
+
84
+ // --- SessionStart ---
85
+
86
+ export async function handlePreHook(projectRoot) {
87
+ try {
88
+ debugLog('SessionStart', 'fired');
89
+ const stdin = readStdin();
90
+ const agentName = await detectAgent(projectRoot, stdin.session_id);
91
+ debugLog('SessionStart', `agent=${agentName || 'none'} session=${stdin.session_id || 'none'}`);
92
+ if (!agentName) return;
93
+
94
+ // Update PID in registry (new process on --resume) + register for event bus
95
+ try {
96
+ const { registerPid } = await import('./pid-checker.js');
97
+ registerPid(projectRoot, agentName, process.ppid || process.pid, stdin.session_id);
98
+ } catch (e) { debugLog('SessionStart', `registerPid: ${e.message}`); }
99
+
100
+ // Poll + inject pending events + purge expired
101
+ try {
102
+ const { consumeAllEvents, formatEventsForInjection, purgeExpired } = await import('./event-bus.js');
103
+ purgeExpired(projectRoot);
104
+ const events = consumeAllEvents(projectRoot, agentName);
105
+ if (events.length > 0) {
106
+ process.stdout.write(formatEventsForInjection(events));
107
+ }
108
+ } catch (e) { debugLog('SessionStart', `event-bus: ${e.message}`); }
109
+
110
+ // Consume .pending-cr (injection CR at resume)
111
+ const crText = consumePendingCr(projectRoot, agentName);
112
+ if (crText) {
113
+ process.stdout.write(crText);
114
+ }
115
+
116
+ // Resolve role + projectId for unified context
117
+ const ctx = resolveAgentContext(projectRoot, agentName);
118
+ const role = ctx?.isLeader ? 'leader' : 'expert';
119
+ const projectId = ctx?.projectId || '';
120
+
121
+ // Write initial situation
122
+ writeCurrentSituation(projectRoot, 'onboarding');
123
+
124
+ // Single unified context injection (replaces 3 separate calls)
125
+ const contextInjection = await buildUnifiedContext(projectRoot, agentName, role, 'onboarding', { projectId });
126
+ if (contextInjection) {
127
+ process.stdout.write(contextInjection);
128
+ debugLog('SessionStart', `unified context injected for ${agentName} (${role})`);
129
+ }
130
+ } catch (e) { debugLog('SessionStart', `FAILED: ${e.message}`); }
131
+ }
132
+
133
+ // --- UserPromptSubmit ---
134
+
135
+ export async function handleDynamicHook(projectRoot) {
136
+ try {
137
+ debugLog('UserPromptSubmit', 'fired');
138
+ const stdin = readStdin();
139
+ const sessionId = stdin.session_id;
140
+ const prompt = stdin.prompt;
141
+ debugLog('UserPromptSubmit', `session=${sessionId || 'none'} prompt=${(prompt || '').slice(0, 50)}`);
142
+ if (!sessionId || !prompt) return;
143
+
144
+ const agentName = await detectAgent(projectRoot, sessionId);
145
+ if (!agentName) return;
146
+
147
+ // Detect situation and inject update if situation changed
148
+ try {
149
+ const { situation } = detectSituation(prompt);
150
+ const currentSituation = readCurrentSituation(projectRoot);
151
+ if (situation !== currentSituation && situation !== 'general') {
152
+ writeCurrentSituation(projectRoot, situation);
153
+ const update = await buildSituationUpdate(projectRoot, situation);
154
+ if (update) {
155
+ process.stdout.write(update);
156
+ debugLog('UserPromptSubmit', `situation changed: ${currentSituation} → ${situation}`);
157
+ }
158
+ }
159
+ } catch (e) { debugLog('UserPromptSubmit', `situation detect FAILED: ${e.message}`); }
160
+
161
+ // Poll + inject pending events (ALL agents, not just Leader)
162
+ try {
163
+ const { consumeAllEvents, formatEventsForInjection } = await import('./event-bus.js');
164
+ const events = consumeAllEvents(projectRoot, agentName);
165
+ if (events.length > 0) {
166
+ process.stdout.write(formatEventsForInjection(events));
167
+ }
168
+ } catch (e) { debugLog('UserPromptSubmit', `event-bus: ${e.message}`); }
169
+
170
+ const nwConfig = readNotewriterConfig(projectRoot);
171
+ if (!nwConfig.note_enabled) return;
172
+
173
+ // Calculate turn number from temp counter
174
+ const counterPath = join(projectRoot, '.nemesis', `.turn-counter-${sessionId}`);
175
+ let turnNumber = 1;
176
+ if (existsSync(counterPath)) {
177
+ try { turnNumber = parseInt(readFileSync(counterPath, 'utf-8').trim()) + 1; }
178
+ catch (e) { debugLog('UserPromptSubmit', `turnCounter: ${e.message}`); turnNumber = 1; }
179
+ }
180
+ writeFileSync(counterPath, String(turnNumber), 'utf-8');
181
+
182
+ // openTurn — stores in memory + .pending-turn file
183
+ openTurn(projectRoot, agentName, sessionId, turnNumber, prompt);
184
+
185
+ // Store prompt for post-hook bridge
186
+ const lastPromptPath = join(projectRoot, '.nemesis', 'last-prompt.tmp');
187
+ writeFileSync(lastPromptPath, prompt, 'utf-8');
188
+
189
+ // Consume .pending-cr if present
190
+ const crText = consumePendingCr(projectRoot, agentName);
191
+ if (crText) {
192
+ process.stdout.write(crText);
193
+ }
194
+ } catch (e) { debugLog('UserPromptSubmit', `FAILED: ${e.message}`); }
195
+ }
196
+
197
+ // --- Stop ---
198
+
199
+ export async function handlePostHook(projectRoot) {
200
+ try {
201
+ debugLog('Stop', 'fired');
202
+ const stdin = readStdin();
203
+ const sessionId = stdin.session_id;
204
+ debugLog('Stop', `session=${sessionId || 'none'} has_last_msg=${!!stdin.last_assistant_message}`);
205
+ // last_assistant_message = direct from Claude, no file parsing needed
206
+ // extractLastResponse = fallback via transcript JSONL
207
+ const response = stdin.last_assistant_message || extractLastResponse(stdin.transcript_path);
208
+ if (!sessionId) return;
209
+
210
+ const agentName = await detectAgent(projectRoot, sessionId);
211
+ if (!agentName) return;
212
+
213
+ // Dispatch signal — if this agent had a pending dispatch, write result
214
+ const pending = findPendingDispatch(projectRoot, agentName);
215
+ if (pending) {
216
+ const dispatchResult = {
217
+ agentName,
218
+ odmId: pending.metadata.odmId || '',
219
+ projectId: pending.metadata.projectId || '',
220
+ missionId: pending.metadata.missionId || '',
221
+ response: response || '',
222
+ finishedAt: new Date().toISOString(),
223
+ };
224
+ writeDispatchResult(projectRoot, pending.txnId, dispatchResult);
225
+ consumePendingDispatch(projectRoot, pending.txnId);
226
+
227
+ // Auto-deposit: update TXN status SENT → COMPLETED
228
+ try {
229
+ const txnPath = join(projectRoot, '.nemesis', 'HCM', 'transactions', `${pending.txnId}.json`);
230
+ if (existsSync(txnPath)) {
231
+ const txn = JSON.parse(readFileSync(txnPath, 'utf-8'));
232
+ if (txn.transaction_meta) {
233
+ txn.transaction_meta.status = 'COMPLETED';
234
+ txn.transaction_meta.completed_at = new Date().toISOString();
235
+ }
236
+ writeFileSync(txnPath, JSON.stringify(txn, null, 2) + '\n', 'utf-8');
237
+ debugLog('Stop', `TXN ${pending.txnId} → COMPLETED`);
238
+ }
239
+ } catch (e) { debugLog('Stop', `TXN update FAILED: ${e.message}`); }
240
+
241
+ // V2: classify response + route to file + bridge to flowmap
242
+ if (response) {
243
+ try {
244
+ const mod = await import('../sync/service-session.js');
245
+ const callLlm = mod.createServiceCaller(projectRoot, 'dispatcher');
246
+ const { classifyResponse } = await import('./dispatcher.js');
247
+ const context = { agentName, odmId: dispatchResult.odmId, projectId: dispatchResult.projectId };
248
+ const { routing } = await classifyResponse(response, context, callLlm);
249
+
250
+ // Route to files for inter-process consumption
251
+ routeToFile(routing, context, projectRoot);
252
+
253
+ // Bridge to flowmap: detect signal and fire event
254
+ for (const entry of routing) {
255
+ const detected = processDispatchResult(projectRoot, dispatchResult, entry);
256
+ if (detected && detected.missionId) {
257
+ await fireFlowmapEvent(projectRoot, detected.missionId, detected.event);
258
+ debugLog('Stop', `flowmap event fired: ${detected.signalId} → ${detected.missionId}`);
259
+ }
260
+ }
261
+
262
+ // Push event to Leader's inbox for LEADER-targeted routing entries
263
+ try {
264
+ const { pushEvent, EVENT_TYPES } = await import('./event-bus.js');
265
+ const { readRegistry, getLanes: getRegLanes } = await import('../core/registry.js');
266
+ const regForLeader = readRegistry(
267
+ (await import('../core/project.js')).detectProject(projectRoot)?.hcm_dir
268
+ );
269
+ if (regForLeader) {
270
+ const allLanes = getRegLanes(regForLeader);
271
+ const leader = allLanes.find(l => l.leader);
272
+ if (leader) {
273
+ for (const entry of routing) {
274
+ if (entry.target === 'LEADER') {
275
+ pushEvent(projectRoot, {
276
+ target_agent_id: leader.id || leader.name,
277
+ event_type: EVENT_TYPES.CR_DEPOSITED,
278
+ source_agent_id: agentName,
279
+ priority: entry.priority || 'MEDIUM',
280
+ payload: { summary: entry.summary || '', odmId: dispatchResult.odmId },
281
+ });
282
+ }
283
+ }
284
+ }
285
+ }
286
+ } catch (e) { debugLog('Stop', `event-bus push to leader FAILED: ${e.message}`); }
287
+ } catch (e) { debugLog('Stop', `dispatch classify FAILED: ${e.message}`); }
288
+ }
289
+ }
290
+
291
+ // Note: NE PAS unregisterPid ici — Stop != session end
292
+ // L'agent peut etre repris via --resume
293
+
294
+ const nwConfig = readNotewriterConfig(projectRoot);
295
+ debugLog('Stop', `note_enabled=${nwConfig.note_enabled}`);
296
+
297
+ // 1. closeTurn (reads .pending-turn if Map empty — inter-process)
298
+ const turnEntry = closeTurn(projectRoot, agentName, sessionId, response || '');
299
+ debugLog('Stop', `turnEntry=${turnEntry ? 'found' : 'null'}`);
300
+
301
+ // 2. generateNote (if enabled)
302
+ if (nwConfig.note_enabled && turnEntry) {
303
+ let callLlm;
304
+ try {
305
+ const mod = await import('../sync/service-session.js');
306
+ callLlm = mod.createServiceCaller(projectRoot, 'notes');
307
+ debugLog('Stop', 'callLlm created');
308
+ } catch (e) {
309
+ // Service LLM not configured — skip
310
+ debugLog('Stop', `callLlm FAILED: ${e.message}`);
311
+ return;
312
+ }
313
+
314
+ await generateNote(projectRoot, agentName, turnEntry, callLlm);
315
+ debugLog('Stop', 'note generated');
316
+
317
+ // 3. Check CR timer (lazy)
318
+ if (nwConfig.cr_enabled) {
319
+ if (shouldGenerateCR(projectRoot, agentName, nwConfig.cr_timer_minutes)) {
320
+ await generateCR(projectRoot, agentName, 'timer_30min', callLlm);
321
+ }
322
+ }
323
+ }
324
+ } catch (err) {
325
+ // Never crash — log warning, exit 0
326
+ console.error(`NoteWriter hook error: ${err.message}`);
327
+ }
328
+ }
329
+
330
+ // --- PreCompact ---
331
+
332
+ export async function handlePreCompact(projectRoot) {
333
+ try {
334
+ const stdin = readStdin();
335
+ const agentName = await detectAgent(projectRoot, stdin.session_id);
336
+ if (!agentName) return;
337
+
338
+ // Resolve role + projectId
339
+ const ctx = resolveAgentContext(projectRoot, agentName);
340
+ const role = ctx?.isLeader ? 'leader' : 'expert';
341
+ const projectId = ctx?.projectId || '';
342
+ const situation = readCurrentSituation(projectRoot) || 'general';
343
+
344
+ // Single unified context re-injection (same as SessionStart)
345
+ const contextInjection = await buildUnifiedContext(projectRoot, agentName, role, situation, { projectId });
346
+ if (contextInjection) {
347
+ process.stdout.write(contextInjection);
348
+ debugLog('PreCompact', `unified context re-injected for ${agentName}`);
349
+ }
350
+ } catch (e) { debugLog('PreCompact', `FAILED: ${e.message}`); }
351
+ }