@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.
- package/LICENSE +201 -0
- package/README.md +668 -0
- package/lib/core/agent-launcher.js +193 -0
- package/lib/core/audit.js +210 -0
- package/lib/core/connexions.js +80 -0
- package/lib/core/flowmap/api.js +111 -0
- package/lib/core/flowmap/cli-helpers.js +80 -0
- package/lib/core/flowmap/machine.js +281 -0
- package/lib/core/flowmap/persistence.js +83 -0
- package/lib/core/generators.js +183 -0
- package/lib/core/inbox.js +275 -0
- package/lib/core/logger.js +20 -0
- package/lib/core/mission.js +109 -0
- package/lib/core/notewriter/config.js +36 -0
- package/lib/core/notewriter/cr.js +237 -0
- package/lib/core/notewriter/log.js +112 -0
- package/lib/core/notewriter/notes.js +168 -0
- package/lib/core/notewriter/paths.js +45 -0
- package/lib/core/notewriter/reader.js +121 -0
- package/lib/core/notewriter/registry.js +80 -0
- package/lib/core/odm.js +191 -0
- package/lib/core/profile-picker.js +323 -0
- package/lib/core/project.js +287 -0
- package/lib/core/registry.js +129 -0
- package/lib/core/secrets.js +137 -0
- package/lib/core/services.js +45 -0
- package/lib/core/team.js +287 -0
- package/lib/core/templates.js +80 -0
- package/lib/kairos/agent-runner.js +261 -0
- package/lib/kairos/claude-invoker.js +90 -0
- package/lib/kairos/context-injector.js +331 -0
- package/lib/kairos/context-loader.js +108 -0
- package/lib/kairos/context-writer.js +45 -0
- package/lib/kairos/dispatcher-router.js +173 -0
- package/lib/kairos/dispatcher.js +139 -0
- package/lib/kairos/event-bus.js +287 -0
- package/lib/kairos/event-router.js +131 -0
- package/lib/kairos/flowmap-bridge.js +120 -0
- package/lib/kairos/hook-handlers.js +351 -0
- package/lib/kairos/hook-installer.js +207 -0
- package/lib/kairos/hook-prompts.js +54 -0
- package/lib/kairos/leader-rules.js +94 -0
- package/lib/kairos/pid-checker.js +108 -0
- package/lib/kairos/situation-detector.js +123 -0
- package/lib/sync/fallback-engine.js +97 -0
- package/lib/sync/hcm-client.js +170 -0
- package/lib/sync/health.js +47 -0
- package/lib/sync/llm-client.js +387 -0
- package/lib/sync/nemesis-client.js +379 -0
- package/lib/sync/service-session.js +74 -0
- package/lib/sync/sync-engine.js +178 -0
- package/lib/ui/box.js +104 -0
- package/lib/ui/brand.js +42 -0
- package/lib/ui/colors.js +57 -0
- package/lib/ui/dashboard.js +580 -0
- package/lib/ui/error-hints.js +49 -0
- package/lib/ui/format.js +61 -0
- package/lib/ui/menu.js +306 -0
- package/lib/ui/note-card.js +198 -0
- package/lib/ui/note-colors.js +26 -0
- package/lib/ui/note-detail.js +297 -0
- package/lib/ui/note-filters.js +252 -0
- package/lib/ui/note-views.js +283 -0
- package/lib/ui/prompt.js +81 -0
- package/lib/ui/spinner.js +139 -0
- package/lib/ui/streambox.js +46 -0
- package/lib/ui/table.js +42 -0
- package/lib/ui/tree.js +33 -0
- package/package.json +53 -0
- package/src/cli.js +457 -0
- package/src/commands/_helpers.js +119 -0
- package/src/commands/audit.js +187 -0
- package/src/commands/auth.js +316 -0
- package/src/commands/doctor.js +243 -0
- package/src/commands/hcm.js +147 -0
- package/src/commands/inbox.js +333 -0
- package/src/commands/init.js +160 -0
- package/src/commands/kairos.js +216 -0
- package/src/commands/kars.js +134 -0
- package/src/commands/mission.js +275 -0
- package/src/commands/notes.js +316 -0
- package/src/commands/notewriter.js +296 -0
- package/src/commands/odm.js +329 -0
- package/src/commands/orch.js +68 -0
- package/src/commands/project.js +123 -0
- package/src/commands/run.js +123 -0
- package/src/commands/services.js +705 -0
- package/src/commands/status.js +231 -0
- package/src/commands/team.js +572 -0
- package/src/config.js +84 -0
- package/src/index.js +5 -0
- package/templates/project-context.json +10 -0
- package/templates/template_CONTRIB-NAME.json +22 -0
- package/templates/template_CR-ODM-NAME-000.exemple.json +32 -0
- package/templates/template_DEC-NAME-000.json +18 -0
- package/templates/template_INTV-NAME-000.json +15 -0
- package/templates/template_MISSION_CONTRACT.json +46 -0
- package/templates/template_ODM-NAME-000.json +89 -0
- package/templates/template_REGISTRY-PROJECT.json +26 -0
- package/templates/template_TXN-NAME-000.json +24 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { debug } from '../core/logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* New Claude Code hooks format (2025+):
|
|
7
|
+
* Each event is an array of matcher groups, each group has a `hooks` array.
|
|
8
|
+
* { "hooks": { "EventName": [{ "matcher": "...", "hooks": [{ "type": "command", "command": "..." }] }] } }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const KAIROS_HOOKS = {
|
|
12
|
+
SessionStart: [{ hooks: [{ type: 'command', command: 'nemesis kairos pre-hook' }] }],
|
|
13
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'nemesis kairos dynamic-hook' }] }],
|
|
14
|
+
Stop: [{ hooks: [{ type: 'command', command: 'nemesis kairos post-hook' }] }],
|
|
15
|
+
PreCompact: [{ hooks: [{ type: 'command', command: 'nemesis kairos pre-compact' }] }],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const ALL_HOOKS = {
|
|
19
|
+
SessionStart: { command: 'nemesis kairos pre-hook', label: 'Pre-hook' },
|
|
20
|
+
UserPromptSubmit: { command: 'nemesis kairos dynamic-hook', label: 'Dynamic-hook' },
|
|
21
|
+
Stop: { command: 'nemesis kairos post-hook', label: 'Post-hook' },
|
|
22
|
+
PreCompact: { command: 'nemesis kairos pre-compact', label: 'Pre-compact' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function resolveSettingsPath(projectRoot) {
|
|
26
|
+
return join(projectRoot, '.claude', 'settings.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a command exists in a matcher group array (new format).
|
|
31
|
+
*/
|
|
32
|
+
function hasCommand(groups, cmd) {
|
|
33
|
+
return (groups || []).some(
|
|
34
|
+
group => (group.hooks || []).some(h => h.command === cmd),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function installHooks(projectRoot) {
|
|
39
|
+
const settingsPath = resolveSettingsPath(projectRoot);
|
|
40
|
+
const settingsDir = dirname(settingsPath);
|
|
41
|
+
|
|
42
|
+
let settings = {};
|
|
43
|
+
let merged = false;
|
|
44
|
+
if (existsSync(settingsPath)) {
|
|
45
|
+
try {
|
|
46
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
47
|
+
merged = true;
|
|
48
|
+
} catch (e) { debug(`installHooks parse: ${e.message}`); settings = {}; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Merge hooks (new format)
|
|
52
|
+
if (!settings.hooks) settings.hooks = {};
|
|
53
|
+
for (const [event, matcherGroups] of Object.entries(KAIROS_HOOKS)) {
|
|
54
|
+
if (!settings.hooks[event]) {
|
|
55
|
+
settings.hooks[event] = matcherGroups;
|
|
56
|
+
} else {
|
|
57
|
+
const nemesisCmd = matcherGroups[0].hooks[0].command;
|
|
58
|
+
if (!hasCommand(settings.hooks[event], nemesisCmd)) {
|
|
59
|
+
settings.hooks[event].push(...matcherGroups);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Write
|
|
65
|
+
if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
|
|
66
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
67
|
+
|
|
68
|
+
return { installed: true, path: settingsPath, merged };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function verifyHooks(projectRoot) {
|
|
72
|
+
const settingsPath = resolveSettingsPath(projectRoot);
|
|
73
|
+
const emptyHooks = {};
|
|
74
|
+
for (const event of Object.keys(ALL_HOOKS)) emptyHooks[event] = false;
|
|
75
|
+
|
|
76
|
+
if (!existsSync(settingsPath)) {
|
|
77
|
+
return { ok: false, hooks: emptyHooks, settingsExists: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let settings;
|
|
81
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); }
|
|
82
|
+
catch (e) {
|
|
83
|
+
debug(`verifyHooks parse: ${e.message}`);
|
|
84
|
+
return { ok: false, hooks: emptyHooks, settingsExists: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hooks = {};
|
|
88
|
+
for (const [event, meta] of Object.entries(ALL_HOOKS)) {
|
|
89
|
+
hooks[event] = hasCommand(settings.hooks?.[event], meta.command);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const allActive = Object.values(hooks).every(Boolean);
|
|
93
|
+
return {
|
|
94
|
+
ok: allActive,
|
|
95
|
+
hooks,
|
|
96
|
+
settingsExists: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getHookStatus(projectRoot) {
|
|
101
|
+
const settingsPath = resolveSettingsPath(projectRoot);
|
|
102
|
+
let settings = {};
|
|
103
|
+
if (existsSync(settingsPath)) {
|
|
104
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); }
|
|
105
|
+
catch (e) { debug(`getHookStatus parse: ${e.message}`); settings = {}; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const status = {};
|
|
109
|
+
for (const [event, meta] of Object.entries(ALL_HOOKS)) {
|
|
110
|
+
const active = hasCommand(settings.hooks?.[event], meta.command);
|
|
111
|
+
status[event] = { active, command: meta.command };
|
|
112
|
+
}
|
|
113
|
+
return status;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Activate a single hook by event name. Direct JSON write — no LLM.
|
|
118
|
+
*/
|
|
119
|
+
export function activateSingleHook(projectRoot, hookEvent) {
|
|
120
|
+
const meta = ALL_HOOKS[hookEvent];
|
|
121
|
+
if (!meta) return { ok: false, error: `Hook inconnu: ${hookEvent}` };
|
|
122
|
+
|
|
123
|
+
const settingsPath = resolveSettingsPath(projectRoot);
|
|
124
|
+
const settingsDir = dirname(settingsPath);
|
|
125
|
+
|
|
126
|
+
let settings = {};
|
|
127
|
+
if (existsSync(settingsPath)) {
|
|
128
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); }
|
|
129
|
+
catch (e) { debug(`activateSingleHook parse: ${e.message}`); settings = {}; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!settings.hooks) settings.hooks = {};
|
|
133
|
+
if (!settings.hooks[hookEvent]) settings.hooks[hookEvent] = [];
|
|
134
|
+
|
|
135
|
+
if (hasCommand(settings.hooks[hookEvent], meta.command)) {
|
|
136
|
+
return { ok: true, alreadyActive: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
settings.hooks[hookEvent].push({ hooks: [{ type: 'command', command: meta.command }] });
|
|
140
|
+
|
|
141
|
+
if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
|
|
142
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
143
|
+
return { ok: true, alreadyActive: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Deactivate a single hook by event name. Direct JSON write — no LLM.
|
|
148
|
+
*/
|
|
149
|
+
export function deactivateSingleHook(projectRoot, hookEvent) {
|
|
150
|
+
const meta = ALL_HOOKS[hookEvent];
|
|
151
|
+
if (!meta) return { ok: false, error: `Hook inconnu: ${hookEvent}` };
|
|
152
|
+
|
|
153
|
+
const settingsPath = resolveSettingsPath(projectRoot);
|
|
154
|
+
if (!existsSync(settingsPath)) return { ok: true, alreadyInactive: true };
|
|
155
|
+
|
|
156
|
+
let settings;
|
|
157
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); }
|
|
158
|
+
catch (e) { debug(`deactivateSingleHook parse: ${e.message}`); return { ok: false, error: 'settings.json non parsable' }; }
|
|
159
|
+
|
|
160
|
+
if (!settings.hooks?.[hookEvent]) return { ok: true, alreadyInactive: true };
|
|
161
|
+
|
|
162
|
+
if (!hasCommand(settings.hooks[hookEvent], meta.command)) {
|
|
163
|
+
return { ok: true, alreadyInactive: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
settings.hooks[hookEvent] = settings.hooks[hookEvent].filter(
|
|
167
|
+
group => !(group.hooks || []).some(h => h.command === meta.command),
|
|
168
|
+
);
|
|
169
|
+
if (settings.hooks[hookEvent].length === 0) delete settings.hooks[hookEvent];
|
|
170
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
171
|
+
|
|
172
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
173
|
+
return { ok: true, alreadyInactive: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function removeHooks(projectRoot) {
|
|
177
|
+
const settingsPath = resolveSettingsPath(projectRoot);
|
|
178
|
+
if (!existsSync(settingsPath)) return { removed: false, reason: 'settings.json absent' };
|
|
179
|
+
|
|
180
|
+
let settings;
|
|
181
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); }
|
|
182
|
+
catch (e) { debug(`removeHooks parse: ${e.message}`); return { removed: false, reason: 'settings.json non parsable' }; }
|
|
183
|
+
|
|
184
|
+
if (!settings.hooks) return { removed: false, reason: 'Pas de hooks' };
|
|
185
|
+
|
|
186
|
+
// Remove nemesis matcher groups from each event
|
|
187
|
+
for (const [event, matcherGroups] of Object.entries(KAIROS_HOOKS)) {
|
|
188
|
+
if (!settings.hooks[event]) continue;
|
|
189
|
+
const nemesisCmd = matcherGroups[0].hooks[0].command;
|
|
190
|
+
settings.hooks[event] = settings.hooks[event].filter(
|
|
191
|
+
group => !(group.hooks || []).some(h => h.command === nemesisCmd),
|
|
192
|
+
);
|
|
193
|
+
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Remove legacy Kairos contextFiles (migration cleanup)
|
|
197
|
+
if (settings.contextFiles) {
|
|
198
|
+
settings.contextFiles = settings.contextFiles.filter(cf => cf !== '.nemesis/kairos-context.md');
|
|
199
|
+
if (settings.contextFiles.length === 0) delete settings.contextFiles;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Clean empty hooks object
|
|
203
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
204
|
+
|
|
205
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
206
|
+
return { removed: true };
|
|
207
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function generateActivationPrompt(hookEvent, command) {
|
|
5
|
+
return `Tu es en train de modifier les hooks Kairos pour ce projet.
|
|
6
|
+
|
|
7
|
+
Modifie le fichier \`.claude/settings.json\` a la racine du projet courant
|
|
8
|
+
(PAS ~/.claude/settings.json — hooks par projet uniquement).
|
|
9
|
+
|
|
10
|
+
ACTION : Ajouter le hook suivant si absent :
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"hooks": {
|
|
14
|
+
"${hookEvent}": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{ "type": "command", "command": "${command}" }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Regles :
|
|
25
|
+
- Si \`.claude/settings.json\` n'existe pas, cree-le.
|
|
26
|
+
- Si le fichier existe, merge sans ecraser les entrees existantes.
|
|
27
|
+
- Ne touche a aucune autre cle du fichier.
|
|
28
|
+
- Confirme avec : "HOOK_ACTIVATED_OK"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateDeactivationPrompt(hookEvent, command) {
|
|
32
|
+
return `Tu es en train de modifier les hooks Kairos pour ce projet.
|
|
33
|
+
|
|
34
|
+
Modifie le fichier \`.claude/settings.json\` a la racine du projet courant.
|
|
35
|
+
|
|
36
|
+
ACTION : Supprimer le groupe de hooks contenant la commande "${command}" dans hooks.${hookEvent}.
|
|
37
|
+
|
|
38
|
+
Exemple de ce qu'il faut supprimer :
|
|
39
|
+
{ "hooks": [{ "type": "command", "command": "${command}" }] }
|
|
40
|
+
|
|
41
|
+
Regles :
|
|
42
|
+
- Si d'autres entrees existent dans hooks.${hookEvent}, les conserver.
|
|
43
|
+
- Ne touche a aucune autre cle du fichier.
|
|
44
|
+
- Si l'entree est absente, ne rien faire.
|
|
45
|
+
- Confirme avec : "HOOK_DEACTIVATED_OK"`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writePromptFile(projectRoot, content) {
|
|
49
|
+
const dir = join(projectRoot, '.nemesis', 'kairos');
|
|
50
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
51
|
+
const filePath = join(dir, 'hooks-prompt.md');
|
|
52
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
53
|
+
return filePath;
|
|
54
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const LEADER_ROLE = {
|
|
2
|
+
nature: 'organisateur — ne produit pas, ne livre pas, ne code pas',
|
|
3
|
+
chaine: 'Mission Contract → OdM → Transaction assignation → CR-OdM → CR-Mission',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const LEADER_RULES = [
|
|
7
|
+
'Cadrer le Mission Contract avec le PM.',
|
|
8
|
+
'Creer les OdMs OBLIGATOIREMENT via : nemesis odm init (utilise le template officiel odm_meta/odm_payload).',
|
|
9
|
+
'Creer une Transaction dans transactions/ a chaque assignation OdM.',
|
|
10
|
+
'Suivre les statuts OdM : DRAFT → ASSIGNED → IN_PROGRESS → DONE.',
|
|
11
|
+
'Lire chaque CR-OdM depose par les Experts.',
|
|
12
|
+
'Valider (VALIDE) ou rejeter (REJET) avec motif explicite.',
|
|
13
|
+
'Deposer un CR-Mission final quand tous les OdMs sont VALIDE.',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const LEADER_DISPATCH = {
|
|
17
|
+
description: 'Dispatcher un OdM via le daemon orchestrateur nemesis orch.',
|
|
18
|
+
command: 'nemesis odm set-status <ODM_ID> READY_TO_DISPATCH',
|
|
19
|
+
flow: [
|
|
20
|
+
'Proposer le dispatch au PM et attendre validation.',
|
|
21
|
+
'Mettre l\'OdM en READY_TO_DISPATCH : nemesis odm set-status <ID> READY_TO_DISPATCH',
|
|
22
|
+
'Le daemon nemesis orch detecte et dispatche automatiquement a l\'agent assigne.',
|
|
23
|
+
'L\'agent tourne en headless. Son Stop hook depose le resultat.',
|
|
24
|
+
'Le daemon route le resultat selon routing.chain et te notifie via event-bus.',
|
|
25
|
+
'A reception : classifier la sortie et effectuer la QA.',
|
|
26
|
+
],
|
|
27
|
+
qa: [
|
|
28
|
+
'Si un agent QA existe dans l\'equipe → lui assigner la QA.',
|
|
29
|
+
'Si aucun agent QA → le Leader fait la QA lui-meme.',
|
|
30
|
+
'QA = verifier que le livrable couvre 100% de l\'OdM.',
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const LEADER_INTERDICTIONS = [
|
|
35
|
+
'Ne pas produire de livrables metier.',
|
|
36
|
+
'Ne pas modifier les livrables des Experts.',
|
|
37
|
+
'Ne pas sauter de gate.',
|
|
38
|
+
'Ne pas valider un CR sans l\'avoir lu integralement.',
|
|
39
|
+
'Ne JAMAIS lancer claude --resume directement. Utiliser nemesis odm set-status pour mettre en READY_TO_DISPATCH.',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export const LEADER_VALIDATION = {
|
|
43
|
+
VALIDE: 'Le livrable couvre 100% des elements demandes dans l\'OdM sans exception ni simplification.',
|
|
44
|
+
REJET: [
|
|
45
|
+
'Au moins un element de l\'OdM non couvert.',
|
|
46
|
+
'Simplification ou raccourci detecte par rapport aux exigences.',
|
|
47
|
+
'Hors perimetre OdM.',
|
|
48
|
+
'Rejection criteria declenche.',
|
|
49
|
+
'Incoherence interne dans le livrable.',
|
|
50
|
+
],
|
|
51
|
+
sur_rejet: 'Rouvrir l\'OdM avec champ motif_rejet explicite → repasser en ASSIGNED + nouvelle Transaction.',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const LEADER_PERIMETRE = {
|
|
55
|
+
lecture_seule: ['.nemesis/HCM/cr/', '.nemesis/HCM/Note/'],
|
|
56
|
+
ecriture_interdite: ['.nemesis/HCM/project/', '.nemesis/claude/'],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function formatLeaderContext(agentName) {
|
|
60
|
+
const lines = [
|
|
61
|
+
'# Mode Leader', '',
|
|
62
|
+
`Agent: ${agentName}`,
|
|
63
|
+
`Nature: ${LEADER_ROLE.nature}`,
|
|
64
|
+
`Chaine: ${LEADER_ROLE.chaine}`, '',
|
|
65
|
+
'## Responsabilites', '',
|
|
66
|
+
...LEADER_RULES.map(r => `- ${r}`), '',
|
|
67
|
+
'## Interdictions', '',
|
|
68
|
+
...LEADER_INTERDICTIONS.map(r => `- ${r}`), '',
|
|
69
|
+
'## Dispatch OdM', '',
|
|
70
|
+
'OBLIGATOIRE : pour dispatcher un OdM, mets-le en READY_TO_DISPATCH. Le daemon nemesis orch le dispatche automatiquement.',
|
|
71
|
+
'Ne JAMAIS lancer claude --resume manuellement.', '',
|
|
72
|
+
`1. Verifie les agents disponibles :`,
|
|
73
|
+
` nemesis team list`, '',
|
|
74
|
+
`2. Mets l'OdM en READY_TO_DISPATCH :`,
|
|
75
|
+
` ${LEADER_DISPATCH.command}`, '',
|
|
76
|
+
...LEADER_DISPATCH.flow.map(r => `- ${r}`), '',
|
|
77
|
+
'## QA', '',
|
|
78
|
+
...LEADER_DISPATCH.qa.map(r => `- ${r}`), '',
|
|
79
|
+
'## Validation CR', '',
|
|
80
|
+
`- VALIDE : ${LEADER_VALIDATION.VALIDE}`,
|
|
81
|
+
'- REJET :',
|
|
82
|
+
...LEADER_VALIDATION.REJET.map(r => ` - ${r}`),
|
|
83
|
+
`- Sur rejet : ${LEADER_VALIDATION.sur_rejet}`, '',
|
|
84
|
+
'## Perimetre fichiers', '',
|
|
85
|
+
'- Lecture seule :',
|
|
86
|
+
...LEADER_PERIMETRE.lecture_seule.map(p => ` - ${p}`),
|
|
87
|
+
'- Ecriture interdite :',
|
|
88
|
+
...LEADER_PERIMETRE.ecriture_interdite.map(p => ` - ${p}`), '',
|
|
89
|
+
'Format: JSON structure dans les fichiers HCM. Prose libre uniquement dans summary et motif_rejet. Chaque action tracee — aucune decision implicite.',
|
|
90
|
+
'',
|
|
91
|
+
];
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readRegistry, getLanes, patchLaneFields } from '../core/registry.js';
|
|
2
|
+
import { detectProject } from '../core/project.js';
|
|
3
|
+
import { debug } from '../core/logger.js';
|
|
4
|
+
|
|
5
|
+
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24h
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a PID is alive using kill -0 (no-op signal).
|
|
9
|
+
* @param {number|null} pid
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function isPidAlive(pid) {
|
|
13
|
+
if (!pid) return false;
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
debug(`isPidAlive ${pid}: ${e.message}`);
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Verify if an agent is alive with PID recycling protection.
|
|
25
|
+
* Combines pid + session_id + pid_registered_at timestamp.
|
|
26
|
+
* @param {object} lane - Registry lane object
|
|
27
|
+
* @returns {{ alive: boolean, stale: boolean, reason: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function verifyAgentAlive(lane) {
|
|
30
|
+
if (!lane) return { alive: false, stale: false, reason: 'no_lane' };
|
|
31
|
+
if (!lane.pid) return { alive: false, stale: false, reason: 'no_pid' };
|
|
32
|
+
|
|
33
|
+
const alive = isPidAlive(lane.pid);
|
|
34
|
+
if (!alive) return { alive: false, stale: false, reason: 'pid_dead' };
|
|
35
|
+
|
|
36
|
+
// PID is alive — check for recycling via pid_registered_at
|
|
37
|
+
if (lane.pid_registered_at) {
|
|
38
|
+
const registeredAt = new Date(lane.pid_registered_at).getTime();
|
|
39
|
+
const age = Date.now() - registeredAt;
|
|
40
|
+
if (age > STALE_THRESHOLD_MS) {
|
|
41
|
+
return { alive: true, stale: true, reason: 'pid_stale_24h' };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { alive: true, stale: false, reason: 'ok' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get statuses for all agents in the registry.
|
|
50
|
+
* @param {string} projectRoot
|
|
51
|
+
* @returns {Array<{ agentId: string, alive: boolean, stale: boolean, pid: number|null, session_id: string|null }>}
|
|
52
|
+
*/
|
|
53
|
+
export function getAgentStatuses(projectRoot) {
|
|
54
|
+
const project = detectProject(projectRoot);
|
|
55
|
+
if (!project) return [];
|
|
56
|
+
const reg = readRegistry(project.hcm_dir);
|
|
57
|
+
if (!reg) return [];
|
|
58
|
+
const lanes = getLanes(reg);
|
|
59
|
+
|
|
60
|
+
return lanes.map(lane => {
|
|
61
|
+
const { alive, stale } = verifyAgentAlive(lane);
|
|
62
|
+
return {
|
|
63
|
+
agentId: lane.id || lane.name,
|
|
64
|
+
alive,
|
|
65
|
+
stale,
|
|
66
|
+
pid: lane.pid || null,
|
|
67
|
+
session_id: lane.session_id || null,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register a PID for an agent in the registry.
|
|
74
|
+
* Updates pid, pid_registered_at, and session_id.
|
|
75
|
+
* @param {string} projectRoot
|
|
76
|
+
* @param {string} agentId
|
|
77
|
+
* @param {number} pid
|
|
78
|
+
* @param {string} [sessionId]
|
|
79
|
+
*/
|
|
80
|
+
export function registerPid(projectRoot, agentId, pid, sessionId) {
|
|
81
|
+
const project = detectProject(projectRoot);
|
|
82
|
+
if (!project) return;
|
|
83
|
+
|
|
84
|
+
const fields = {
|
|
85
|
+
pid,
|
|
86
|
+
pid_registered_at: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
if (sessionId) fields.session_id = sessionId;
|
|
89
|
+
|
|
90
|
+
// Atomic patch — reads fresh + writes atomically to avoid race conditions
|
|
91
|
+
// (prevents overwriting leader flag or other fields changed by another process)
|
|
92
|
+
patchLaneFields(project.hcm_dir, agentId, fields);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Unregister a PID for an agent. Clears pid and pid_registered_at but preserves session_id.
|
|
97
|
+
* @param {string} projectRoot
|
|
98
|
+
* @param {string} agentId
|
|
99
|
+
*/
|
|
100
|
+
export function unregisterPid(projectRoot, agentId) {
|
|
101
|
+
const project = detectProject(projectRoot);
|
|
102
|
+
if (!project) return;
|
|
103
|
+
|
|
104
|
+
patchLaneFields(project.hcm_dir, agentId, {
|
|
105
|
+
pid: null,
|
|
106
|
+
pid_registered_at: null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Situation Detector — infer situation from user prompt via keyword-matching.
|
|
3
|
+
* Fast (< 10ms), no LLM. Used to select sous-familles for Kairos context.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Situation routing table: keyword → situation + sous-familles.
|
|
8
|
+
* Order matters — first match with highest score wins.
|
|
9
|
+
*/
|
|
10
|
+
const SITUATION_TABLE = [
|
|
11
|
+
{
|
|
12
|
+
situation: 'onboarding',
|
|
13
|
+
keywords: ['onboarding', 'onboard', 'init', 'demarrage', 'demarrer', 'bienvenue', 'setup'],
|
|
14
|
+
sousFamilles: ['NUCLEUS', 'SKILLS_CORE'],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
situation: 'debugging',
|
|
18
|
+
keywords: ['bug', 'erreur', 'error', 'debug', 'fix', 'crash', 'broken', 'casse', 'plantage', 'stack trace', 'stacktrace', 'exception'],
|
|
19
|
+
sousFamilles: ['SKILLS_CORE', 'ERROR_RECOVERY'],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
situation: 'review',
|
|
23
|
+
keywords: ['review', 'revue', 'valide', 'validation', 'valider', 'qa', 'controle qualite', 'relecture', 'approve'],
|
|
24
|
+
sousFamilles: ['NUCLEUS', 'SCOPE', 'RESPONSIBILITIES'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
situation: 'architecture',
|
|
28
|
+
keywords: ['archi', 'architecture', 'design', 'refactor', 'restructur', 'pattern', 'conception', 'schema'],
|
|
29
|
+
sousFamilles: ['NUCLEUS', 'SCOPE', 'DOMAINS', 'RESPONSIBILITIES'],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
situation: 'documentation',
|
|
33
|
+
keywords: ['doc ', 'readme', 'changelog', 'documentation', 'documenter', 'jsdoc', 'api doc', 'docstring'],
|
|
34
|
+
sousFamilles: ['NUCLEUS', 'DOMAINS', 'SCOPE'],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
situation: 'audit',
|
|
38
|
+
keywords: ['audit', 'controle', 'compliance', 'conformite', 'securite', 'security', 'vulnerabilite'],
|
|
39
|
+
sousFamilles: [], // all sous-familles
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
situation: 'implementation',
|
|
43
|
+
keywords: ['implement', 'implemente', 'code', 'develop', 'developper', 'coder', 'ecrire', 'creer', 'ajouter', 'build', 'feature', 'fonctionnalite'],
|
|
44
|
+
sousFamilles: ['NUCLEUS', 'SKILLS_CORE', 'DOMAINS'],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Default situation when no keywords match */
|
|
49
|
+
const DEFAULT_SITUATION = {
|
|
50
|
+
situation: 'general',
|
|
51
|
+
confidence: 0,
|
|
52
|
+
sousFamilles: ['NUCLEUS', 'SKILLS_CORE'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect the situation from a user prompt using keyword scoring.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} prompt - user prompt text
|
|
59
|
+
* @returns {{ situation: string, confidence: number, sousFamilles: string[] }}
|
|
60
|
+
*/
|
|
61
|
+
export function detectSituation(prompt) {
|
|
62
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
63
|
+
return { ...DEFAULT_SITUATION };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const normalized = prompt.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
67
|
+
const scores = [];
|
|
68
|
+
|
|
69
|
+
for (const entry of SITUATION_TABLE) {
|
|
70
|
+
let score = 0;
|
|
71
|
+
let matchCount = 0;
|
|
72
|
+
|
|
73
|
+
for (const kw of entry.keywords) {
|
|
74
|
+
const normalizedKw = kw.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
75
|
+
if (normalized.includes(normalizedKw)) {
|
|
76
|
+
score += normalizedKw.length; // longer keywords = more specific = higher weight
|
|
77
|
+
matchCount++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (matchCount > 0) {
|
|
82
|
+
// Bonus for multiple keyword matches
|
|
83
|
+
score += (matchCount - 1) * 3;
|
|
84
|
+
scores.push({ ...entry, score, matchCount });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (scores.length === 0) {
|
|
89
|
+
return { ...DEFAULT_SITUATION };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sort by score descending
|
|
93
|
+
scores.sort((a, b) => b.score - a.score);
|
|
94
|
+
const best = scores[0];
|
|
95
|
+
|
|
96
|
+
// Confidence: normalize score to 0-1 range (capped at 1.0)
|
|
97
|
+
const confidence = Math.min(1, best.score / 20);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
situation: best.situation,
|
|
101
|
+
confidence: Math.round(confidence * 100) / 100,
|
|
102
|
+
sousFamilles: best.sousFamilles,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get the sous-familles for a known situation.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} situation
|
|
110
|
+
* @returns {string[]}
|
|
111
|
+
*/
|
|
112
|
+
export function getSousFamillesForSituation(situation) {
|
|
113
|
+
const entry = SITUATION_TABLE.find(e => e.situation === situation);
|
|
114
|
+
return entry?.sousFamilles || DEFAULT_SITUATION.sousFamilles;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List all known situations.
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
export function listSituations() {
|
|
122
|
+
return SITUATION_TABLE.map(e => e.situation);
|
|
123
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getConnexion, getConnexionCredential } from '../core/connexions.js';
|
|
2
|
+
import { getServiceConfig, SERVICE_DEFAULTS } from '../core/services.js';
|
|
3
|
+
import { callSession } from './service-session.js';
|
|
4
|
+
|
|
5
|
+
const FALLBACK_TRIGGERS = new Set([
|
|
6
|
+
'TIMEOUT', 'HTTP_5XX', 'HTTP_429', 'HTTP_401', 'HTTP_403', 'NETWORK_ERROR',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a service call with automatic silent fallback.
|
|
11
|
+
* @param {string} root - Project root
|
|
12
|
+
* @param {string} serviceId - Service identifier (notes, kairos)
|
|
13
|
+
* @param {Function} callFn - async ({ connexion, credential, model }) => result
|
|
14
|
+
* For cli_tools providers, callFn receives ({ connexion, model, callSession: fn })
|
|
15
|
+
* where callSession(prompt) sends to the ephemeral session.
|
|
16
|
+
* @returns {{ result?, connexion_used, is_fallback, degraded, logs }}
|
|
17
|
+
*/
|
|
18
|
+
export async function executeWithFallback(root, serviceId, callFn) {
|
|
19
|
+
const config = getServiceConfig(root, serviceId);
|
|
20
|
+
if (!config) throw new Error(`Service "${serviceId}" non configure`);
|
|
21
|
+
|
|
22
|
+
const defaults = SERVICE_DEFAULTS[serviceId] || { timeout: 5, fallback_timeout: 3 };
|
|
23
|
+
const logs = [];
|
|
24
|
+
|
|
25
|
+
// Build chain: [primary, ...fallbacks]
|
|
26
|
+
const chain = [
|
|
27
|
+
{ connexion_id: config.connexion_id, timeout: config.timeout || defaults.timeout },
|
|
28
|
+
...(config.fallback || []),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < chain.length; i++) {
|
|
32
|
+
const entry = chain[i];
|
|
33
|
+
const connexion = getConnexion(entry.connexion_id);
|
|
34
|
+
if (!connexion) {
|
|
35
|
+
logs.push({ step: i, connexion_id: entry.connexion_id, error: 'connexion introuvable', skipped: true });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const credential = getConnexionCredential(connexion.id);
|
|
40
|
+
const model = config.model_override || connexion.model;
|
|
41
|
+
const timeoutMs = (entry.timeout || defaults.timeout) * 1000;
|
|
42
|
+
const isCli = connexion.provider === 'cli_tools';
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
let callPromise;
|
|
46
|
+
if (isCli) {
|
|
47
|
+
// cli_tools — no timeout race (process has its own timeout via execFileSync)
|
|
48
|
+
callPromise = callFn({
|
|
49
|
+
connexion, model,
|
|
50
|
+
callService: (prompt) => callSession(serviceId, prompt, {
|
|
51
|
+
cwd: root, model, timeout: 120,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
callPromise = callFn({ connexion, credential, model });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = isCli
|
|
59
|
+
? await callPromise
|
|
60
|
+
: await Promise.race([callPromise, timeoutPromise(timeoutMs)]);
|
|
61
|
+
|
|
62
|
+
logs.push({ step: i, connexion_id: connexion.id, name: connexion.name, ok: true });
|
|
63
|
+
return { result, connexion_used: connexion.name, is_fallback: i > 0, degraded: false, logs };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const errorType = classifyError(err);
|
|
66
|
+
logs.push({ step: i, connexion_id: connexion.id, name: connexion.name, error: err.message, errorType });
|
|
67
|
+
|
|
68
|
+
if (!FALLBACK_TRIGGERS.has(errorType)) {
|
|
69
|
+
// Definitive error — no fallback
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
// Trigger fallback → continue to next in chain
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// All failed
|
|
77
|
+
return { result: null, connexion_used: null, is_fallback: false, degraded: true, logs };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyError(err) {
|
|
81
|
+
if (err.name === 'AbortError' || err.message === 'timeout') return 'TIMEOUT';
|
|
82
|
+
const msg = (err.message || '').toLowerCase();
|
|
83
|
+
if (msg.includes('etimedout') || msg.includes('timedout')) return 'TIMEOUT';
|
|
84
|
+
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed')) return 'NETWORK_ERROR';
|
|
85
|
+
const status = err.status || err.statusCode;
|
|
86
|
+
if (status >= 500) return 'HTTP_5XX';
|
|
87
|
+
if (status === 429) return 'HTTP_429';
|
|
88
|
+
if (status === 401) return 'HTTP_401';
|
|
89
|
+
if (status === 403) return 'HTTP_403';
|
|
90
|
+
return 'UNKNOWN';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function timeoutPromise(ms) {
|
|
94
|
+
return new Promise((_, reject) => {
|
|
95
|
+
setTimeout(() => reject(new Error('timeout')), ms);
|
|
96
|
+
});
|
|
97
|
+
}
|