@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,287 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, copyFileSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTemplate, mergeTemplate } from './templates.js';
|
|
5
|
+
import { warn } from './logger.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const EMBEDDED_TEMPLATES = join(__dirname, '..', '..', 'templates');
|
|
9
|
+
|
|
10
|
+
const HCM_SUBDIRS = [
|
|
11
|
+
'contracts',
|
|
12
|
+
'odm',
|
|
13
|
+
'cr',
|
|
14
|
+
'transactions',
|
|
15
|
+
'note',
|
|
16
|
+
'decisions',
|
|
17
|
+
'interventions',
|
|
18
|
+
'contributors',
|
|
19
|
+
'project',
|
|
20
|
+
'odm-cr-QA',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Init a new project — scaffold .nemesis/HCM/ structure.
|
|
25
|
+
*/
|
|
26
|
+
export function initProject(opts) {
|
|
27
|
+
const { projectId, projectName, description, root = process.cwd(), pmName, pmId } = opts;
|
|
28
|
+
const hcmDir = join(root, '.nemesis', 'HCM');
|
|
29
|
+
const templateDir = join(root, '.nemesis', 'template');
|
|
30
|
+
const onboardingDir = join(root, '.nemesis', 'onboarding');
|
|
31
|
+
const claudeDir = join(root, '.nemesis', 'claude');
|
|
32
|
+
const stateDir = join(root, '.nemesis', 'state');
|
|
33
|
+
|
|
34
|
+
// 1. Create HCM subdirectories
|
|
35
|
+
const created = [];
|
|
36
|
+
for (const sub of HCM_SUBDIRS) {
|
|
37
|
+
const dir = join(hcmDir, sub);
|
|
38
|
+
if (!existsSync(dir)) {
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
created.push(sub);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Create auxiliary directories
|
|
45
|
+
for (const dir of [templateDir, onboardingDir, claudeDir, stateDir]) {
|
|
46
|
+
if (!existsSync(dir)) {
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Copy embedded templates to .nemesis/template/ (if not present)
|
|
52
|
+
if (existsSync(EMBEDDED_TEMPLATES)) {
|
|
53
|
+
const templateFiles = readdirSync(EMBEDDED_TEMPLATES).filter(f => f.endsWith('.json'));
|
|
54
|
+
for (const file of templateFiles) {
|
|
55
|
+
const dest = join(templateDir, file);
|
|
56
|
+
if (!existsSync(dest)) {
|
|
57
|
+
copyFileSync(join(EMBEDDED_TEMPLATES, file), dest);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 4. Generate CONTEXT_PROJECT
|
|
63
|
+
const contextData = {
|
|
64
|
+
project_meta: {
|
|
65
|
+
project_id: projectId,
|
|
66
|
+
name: projectName,
|
|
67
|
+
description: description || '',
|
|
68
|
+
status: 'ACTIVE',
|
|
69
|
+
created_at: new Date().toISOString(),
|
|
70
|
+
created_by: 'PM',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const contextFile = join(hcmDir, 'project', `CONTEXT_PROJECT_${projectId}.json`);
|
|
74
|
+
if (!existsSync(contextFile)) {
|
|
75
|
+
writeFileSync(contextFile, JSON.stringify(contextData, null, 2) + '\n', 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 5. Generate REGISTRY with PM only
|
|
79
|
+
const registryTemplate = loadTemplate('registry', root);
|
|
80
|
+
const registry = mergeTemplate(registryTemplate, {
|
|
81
|
+
registry_meta: {
|
|
82
|
+
registry_id: `REGISTRY-${projectId}`,
|
|
83
|
+
project_id: projectId,
|
|
84
|
+
updated_at: new Date().toISOString(),
|
|
85
|
+
updated_by: 'nemesis init',
|
|
86
|
+
},
|
|
87
|
+
registry_payload: {
|
|
88
|
+
hierarchy: {
|
|
89
|
+
pm: {
|
|
90
|
+
id: pmId || 'pm',
|
|
91
|
+
name: pmName || 'PM',
|
|
92
|
+
kind: 'human',
|
|
93
|
+
role: 'Product Manager',
|
|
94
|
+
responsibilities: ['validation', 'arbitrage', 'decisions'],
|
|
95
|
+
},
|
|
96
|
+
lanes: [],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const registryFile = join(hcmDir, 'project', `REGISTRY-${projectId}.json`);
|
|
101
|
+
if (!existsSync(registryFile)) {
|
|
102
|
+
writeFileSync(registryFile, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 6. Generate PROCESS.md
|
|
106
|
+
const processFile = join(hcmDir, 'PROCESS.md');
|
|
107
|
+
if (!existsSync(processFile)) {
|
|
108
|
+
const processContent = `# Process — ${projectName || projectId}
|
|
109
|
+
|
|
110
|
+
> Projet : ${projectId}
|
|
111
|
+
> Cree le : ${new Date().toISOString().split('T')[0]}
|
|
112
|
+
|
|
113
|
+
## Workflow
|
|
114
|
+
|
|
115
|
+
1. PM redige Mission Contract
|
|
116
|
+
2. Architecte/QA redige les OdM
|
|
117
|
+
3. PM valide les OdM
|
|
118
|
+
4. Implementeur execute
|
|
119
|
+
5. Architecte/QA review (CR)
|
|
120
|
+
6. PM valide le CR
|
|
121
|
+
|
|
122
|
+
## Conventions
|
|
123
|
+
|
|
124
|
+
- File-first : toute donnee = fichier JSON dans .nemesis/HCM/
|
|
125
|
+
- Les agents deposent dans .nemesis/HCM/, le hook sync vers HCM
|
|
126
|
+
- Le PM utilise nemesis CLI pour piloter
|
|
127
|
+
`;
|
|
128
|
+
writeFileSync(processFile, processContent, 'utf-8');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { hcmDir, created, projectId };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Store HCM connectivity status in CONTEXT_PROJECT.
|
|
136
|
+
*/
|
|
137
|
+
export function storeHcmStatus(hcmDir, projectId, status) {
|
|
138
|
+
const contextFile = join(hcmDir, 'project', `CONTEXT_PROJECT_${projectId}.json`);
|
|
139
|
+
if (!existsSync(contextFile)) return;
|
|
140
|
+
try {
|
|
141
|
+
const data = JSON.parse(readFileSync(contextFile, 'utf-8'));
|
|
142
|
+
data.hcm_status = status;
|
|
143
|
+
writeFileSync(contextFile, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
warn(`project:storeHcmStatus failed for ${projectId}: ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get project status from local files.
|
|
151
|
+
*/
|
|
152
|
+
export function getProjectStatus(hcmDir) {
|
|
153
|
+
const status = {
|
|
154
|
+
odms: [],
|
|
155
|
+
crs: [],
|
|
156
|
+
decisions: [],
|
|
157
|
+
contracts: [],
|
|
158
|
+
team: { pm: null, agents: [] },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Read OdMs
|
|
162
|
+
const odmDir = join(hcmDir, 'odm');
|
|
163
|
+
if (existsSync(odmDir)) {
|
|
164
|
+
status.odms = readdirSync(odmDir)
|
|
165
|
+
.filter(f => f.endsWith('.json') && !f.startsWith('legacy_'))
|
|
166
|
+
.map(f => {
|
|
167
|
+
try {
|
|
168
|
+
const data = JSON.parse(readFileSync(join(odmDir, f), 'utf-8'));
|
|
169
|
+
return {
|
|
170
|
+
id: data.odm_meta?.odm_id || f,
|
|
171
|
+
title: data.odm_payload?.cadrage?.title || '',
|
|
172
|
+
status: data.odm_meta?.status || 'UNKNOWN',
|
|
173
|
+
assignee: data.odm_meta?.assigned_to?.actor_id || '',
|
|
174
|
+
priority: data.odm_meta?.priority || '',
|
|
175
|
+
};
|
|
176
|
+
} catch (e) {
|
|
177
|
+
warn(`project:malformed OdM ${f}: ${e.message}`);
|
|
178
|
+
return { id: f, title: '', status: 'ERROR', assignee: '', priority: '' };
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Read CRs
|
|
184
|
+
const crDir = join(hcmDir, 'cr');
|
|
185
|
+
if (existsSync(crDir)) {
|
|
186
|
+
status.crs = readdirSync(crDir)
|
|
187
|
+
.filter(f => f.endsWith('.json'))
|
|
188
|
+
.map(f => {
|
|
189
|
+
try {
|
|
190
|
+
const data = JSON.parse(readFileSync(join(crDir, f), 'utf-8'));
|
|
191
|
+
return {
|
|
192
|
+
id: data.metadata?.odm_id || f,
|
|
193
|
+
title: data.title || '',
|
|
194
|
+
status: data.metadata?.status || 'UNKNOWN',
|
|
195
|
+
date: data.metadata?.date_execution || '',
|
|
196
|
+
};
|
|
197
|
+
} catch (e) {
|
|
198
|
+
warn(`project:malformed CR ${f}: ${e.message}`);
|
|
199
|
+
return { id: f, title: '', status: 'ERROR', date: '' };
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Read decisions
|
|
205
|
+
const decDir = join(hcmDir, 'decisions');
|
|
206
|
+
if (existsSync(decDir)) {
|
|
207
|
+
status.decisions = readdirSync(decDir)
|
|
208
|
+
.filter(f => f.endsWith('.json'))
|
|
209
|
+
.map(f => {
|
|
210
|
+
try {
|
|
211
|
+
const data = JSON.parse(readFileSync(join(decDir, f), 'utf-8'));
|
|
212
|
+
return {
|
|
213
|
+
id: data.decision_meta?.decision_id || f,
|
|
214
|
+
title: data.decision_payload?.title || '',
|
|
215
|
+
status: data.decision_meta?.status || '',
|
|
216
|
+
};
|
|
217
|
+
} catch (e) {
|
|
218
|
+
warn(`project:malformed decision ${f}: ${e.message}`);
|
|
219
|
+
return { id: f, title: '', status: 'ERROR' };
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Read contracts
|
|
225
|
+
const contractDir = join(hcmDir, 'contracts');
|
|
226
|
+
if (existsSync(contractDir)) {
|
|
227
|
+
status.contracts = readdirSync(contractDir)
|
|
228
|
+
.filter(f => f.endsWith('.json'))
|
|
229
|
+
.map(f => {
|
|
230
|
+
try {
|
|
231
|
+
const data = JSON.parse(readFileSync(join(contractDir, f), 'utf-8'));
|
|
232
|
+
return {
|
|
233
|
+
id: data.contract_meta?.id || f,
|
|
234
|
+
title: data.contract_payload?.cadrage?.title || '',
|
|
235
|
+
status: data.contract_meta?.status || '',
|
|
236
|
+
};
|
|
237
|
+
} catch (e) {
|
|
238
|
+
warn(`project:malformed contract ${f}: ${e.message}`);
|
|
239
|
+
return { id: f, title: '', status: 'ERROR' };
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return status;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Detect the current project from .nemesis/HCM/project/CONTEXT_PROJECT_*.json
|
|
249
|
+
* Backward-compatible: falls back to .owner/HCM/ if .nemesis/ doesn't exist.
|
|
250
|
+
*/
|
|
251
|
+
export function detectProject(root = process.cwd()) {
|
|
252
|
+
// Priority 1: .nemesis/HCM/project/
|
|
253
|
+
let baseDir = '.nemesis';
|
|
254
|
+
let projectDir = join(root, '.nemesis', 'HCM', 'project');
|
|
255
|
+
|
|
256
|
+
// Fallback: .owner/HCM/project/
|
|
257
|
+
if (!existsSync(projectDir)) {
|
|
258
|
+
const legacyDir = join(root, '.owner', 'HCM', 'project');
|
|
259
|
+
if (existsSync(legacyDir)) {
|
|
260
|
+
baseDir = '.owner';
|
|
261
|
+
projectDir = legacyDir;
|
|
262
|
+
} else {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const files = readdirSync(projectDir);
|
|
268
|
+
const ctxFile = files.find(f => f.startsWith('CONTEXT_PROJECT_') && f.endsWith('.json'));
|
|
269
|
+
if (!ctxFile) return null;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const data = JSON.parse(readFileSync(join(projectDir, ctxFile), 'utf-8'));
|
|
273
|
+
return {
|
|
274
|
+
id: data.project_meta?.project_id || ctxFile.replace('CONTEXT_PROJECT_', '').replace('.json', ''),
|
|
275
|
+
name: data.project_meta?.name || null,
|
|
276
|
+
description: data.project_meta?.description || '',
|
|
277
|
+
status: data.project_meta?.status || 'UNKNOWN',
|
|
278
|
+
hcm_dir: join(root, baseDir, 'HCM'),
|
|
279
|
+
hcm_status: data.hcm_status || null,
|
|
280
|
+
root,
|
|
281
|
+
legacy: baseDir === '.owner',
|
|
282
|
+
};
|
|
283
|
+
} catch (e) {
|
|
284
|
+
warn(`project:corrupted project context ${ctxFile}: ${e.message}`);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find the registry file for a project.
|
|
6
|
+
*/
|
|
7
|
+
export function findRegistryFile(hcmDir) {
|
|
8
|
+
const projectDir = join(hcmDir, 'project');
|
|
9
|
+
if (!existsSync(projectDir)) return null;
|
|
10
|
+
|
|
11
|
+
const files = readdirSync(projectDir);
|
|
12
|
+
const registryFile = files.find(f => f.startsWith('REGISTRY-') && f.endsWith('.json'));
|
|
13
|
+
return registryFile ? join(projectDir, registryFile) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read the registry.
|
|
18
|
+
*/
|
|
19
|
+
export function readRegistry(hcmDir) {
|
|
20
|
+
const filepath = findRegistryFile(hcmDir);
|
|
21
|
+
if (!filepath) return null;
|
|
22
|
+
|
|
23
|
+
return JSON.parse(readFileSync(filepath, 'utf-8'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write the registry back (atomic via tmp + rename).
|
|
28
|
+
*/
|
|
29
|
+
export function writeRegistry(hcmDir, data) {
|
|
30
|
+
const filepath = findRegistryFile(hcmDir);
|
|
31
|
+
if (!filepath) throw new Error('Registry introuvable');
|
|
32
|
+
|
|
33
|
+
const tmpPath = filepath + '.tmp';
|
|
34
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
35
|
+
renameSync(tmpPath, filepath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Atomic field-level patch on a lane — reads fresh, patches, writes atomically.
|
|
40
|
+
* Minimizes race-condition window vs separate read/updateLaneField/write.
|
|
41
|
+
* @param {string} hcmDir
|
|
42
|
+
* @param {string} agentId
|
|
43
|
+
* @param {Record<string, any>} fields — { pid: 123, session_id: 'abc', ... }
|
|
44
|
+
*/
|
|
45
|
+
export function patchLaneFields(hcmDir, agentId, fields) {
|
|
46
|
+
const filepath = findRegistryFile(hcmDir);
|
|
47
|
+
if (!filepath) return;
|
|
48
|
+
|
|
49
|
+
const data = JSON.parse(readFileSync(filepath, 'utf-8'));
|
|
50
|
+
const lane = data.registry_payload?.hierarchy?.lanes?.find(l => l.id === agentId);
|
|
51
|
+
if (!lane) return;
|
|
52
|
+
|
|
53
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
54
|
+
lane[key] = value;
|
|
55
|
+
}
|
|
56
|
+
data.registry_meta.updated_at = new Date().toISOString();
|
|
57
|
+
|
|
58
|
+
const tmpPath = filepath + '.tmp';
|
|
59
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
60
|
+
renameSync(tmpPath, filepath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Add a lane (agent) to the registry.
|
|
65
|
+
*/
|
|
66
|
+
export function addLane(registry, lane) {
|
|
67
|
+
if (!registry.registry_payload?.hierarchy?.lanes) {
|
|
68
|
+
throw new Error('Structure registry invalide');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const existing = registry.registry_payload.hierarchy.lanes.find(l => l.id === lane.id);
|
|
72
|
+
if (existing) {
|
|
73
|
+
throw new Error(`Agent ${lane.id} deja present dans le registre`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
registry.registry_payload.hierarchy.lanes.push(lane);
|
|
77
|
+
registry.registry_meta.updated_at = new Date().toISOString();
|
|
78
|
+
return registry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove a lane from the registry.
|
|
83
|
+
*/
|
|
84
|
+
export function removeLane(registry, agentId) {
|
|
85
|
+
if (!registry.registry_payload?.hierarchy?.lanes) {
|
|
86
|
+
throw new Error('Structure registry invalide');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const idx = registry.registry_payload.hierarchy.lanes.findIndex(l => l.id === agentId);
|
|
90
|
+
if (idx === -1) {
|
|
91
|
+
throw new Error(`Agent ${agentId} non trouve dans le registre`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
registry.registry_payload.hierarchy.lanes.splice(idx, 1);
|
|
95
|
+
registry.registry_meta.updated_at = new Date().toISOString();
|
|
96
|
+
return registry;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update a single field on an existing lane.
|
|
101
|
+
*/
|
|
102
|
+
export function updateLaneField(registry, agentId, field, value) {
|
|
103
|
+
if (!registry.registry_payload?.hierarchy?.lanes) {
|
|
104
|
+
throw new Error('Structure registry invalide');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lane = registry.registry_payload.hierarchy.lanes.find(l => l.id === agentId);
|
|
108
|
+
if (!lane) {
|
|
109
|
+
throw new Error(`Agent ${agentId} non trouve dans le registre`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lane[field] = value;
|
|
113
|
+
registry.registry_meta.updated_at = new Date().toISOString();
|
|
114
|
+
return registry;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get all lanes from the registry.
|
|
119
|
+
*/
|
|
120
|
+
export function getLanes(registry) {
|
|
121
|
+
return registry?.registry_payload?.hierarchy?.lanes || [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get PM info from the registry.
|
|
126
|
+
*/
|
|
127
|
+
export function getPM(registry) {
|
|
128
|
+
return registry?.registry_payload?.hierarchy?.pm || null;
|
|
129
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash, pbkdf2Sync } from 'node:crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir, hostname } from 'node:os';
|
|
5
|
+
|
|
6
|
+
const ALGO = 'aes-256-gcm';
|
|
7
|
+
const SECRETS_FILE = join(homedir(), '.nemesis', 'secrets.json');
|
|
8
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
9
|
+
const PBKDF2_KEYLEN = 32;
|
|
10
|
+
const PBKDF2_DIGEST = 'sha512';
|
|
11
|
+
const MIN_API_KEY_LENGTH = 8;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Derive a 32-byte encryption key using PBKDF2.
|
|
15
|
+
* @param {Buffer} salt — random salt
|
|
16
|
+
* @returns {Buffer} 32-byte key
|
|
17
|
+
*/
|
|
18
|
+
export function deriveKey(salt) {
|
|
19
|
+
const passphrase = `${hostname()}:${homedir()}`;
|
|
20
|
+
return pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Legacy key derivation — SHA-256 + hardcoded salt.
|
|
25
|
+
* Used only for migration from old schema.
|
|
26
|
+
*/
|
|
27
|
+
function deriveKeyLegacy() {
|
|
28
|
+
return createHash('sha256')
|
|
29
|
+
.update(`${hostname()}:${homedir()}:nemesis-arka-labs-2026`)
|
|
30
|
+
.digest();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function encrypt(plaintext, key) {
|
|
34
|
+
const iv = randomBytes(12);
|
|
35
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
36
|
+
let encrypted = cipher.update(plaintext, 'utf-8', 'hex');
|
|
37
|
+
encrypted += cipher.final('hex');
|
|
38
|
+
const tag = cipher.getAuthTag().toString('hex');
|
|
39
|
+
return { iv: iv.toString('hex'), tag, data: encrypted };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decrypt(encrypted, key) {
|
|
43
|
+
const decipher = createDecipheriv(ALGO, key, Buffer.from(encrypted.iv, 'hex'));
|
|
44
|
+
decipher.setAuthTag(Buffer.from(encrypted.tag, 'hex'));
|
|
45
|
+
let decrypted = decipher.update(encrypted.data, 'hex', 'utf-8');
|
|
46
|
+
decrypted += decipher.final('utf-8');
|
|
47
|
+
return decrypted;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateApiKey(value) {
|
|
51
|
+
if (!value || typeof value !== 'string') return false;
|
|
52
|
+
return value.trim().length >= MIN_API_KEY_LENGTH;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readSecrets() {
|
|
56
|
+
if (!existsSync(SECRETS_FILE)) return {};
|
|
57
|
+
try { return JSON.parse(readFileSync(SECRETS_FILE, 'utf-8')); }
|
|
58
|
+
catch (_e) { /* fallback: corrupted secrets file */ return {}; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writeSecrets(secrets) {
|
|
62
|
+
const dir = join(homedir(), '.nemesis');
|
|
63
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
64
|
+
writeFileSync(SECRETS_FILE, JSON.stringify(secrets, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Migrate secrets from old schema (SHA-256 + hardcoded salt) to new (PBKDF2 + random salt).
|
|
69
|
+
* Pure function — no I/O. Caller must persist the result.
|
|
70
|
+
*/
|
|
71
|
+
export function migrateSecrets(secrets) {
|
|
72
|
+
if (secrets._salt) return secrets;
|
|
73
|
+
const entries = Object.entries(secrets).filter(([id]) => !id.startsWith('_'));
|
|
74
|
+
if (entries.length === 0) return secrets;
|
|
75
|
+
|
|
76
|
+
const legacyKey = deriveKeyLegacy();
|
|
77
|
+
const salt = randomBytes(32);
|
|
78
|
+
const newKey = deriveKey(salt);
|
|
79
|
+
const migrated = { _salt: salt.toString('base64') };
|
|
80
|
+
|
|
81
|
+
for (const [id, encryptedValue] of entries) {
|
|
82
|
+
try {
|
|
83
|
+
const plaintext = decrypt(encryptedValue, legacyKey);
|
|
84
|
+
migrated[id] = encrypt(plaintext, newKey);
|
|
85
|
+
} catch (_e) {
|
|
86
|
+
/* fallback: cannot decrypt with legacy key — preserve entry as-is */
|
|
87
|
+
migrated[id] = encryptedValue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return migrated;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the encryption key and secrets object.
|
|
96
|
+
* Handles migration from old schema transparently.
|
|
97
|
+
*/
|
|
98
|
+
function getKeyAndSecrets() {
|
|
99
|
+
let secrets = readSecrets();
|
|
100
|
+
|
|
101
|
+
if (!secrets._salt) {
|
|
102
|
+
if (Object.keys(secrets).length > 0) {
|
|
103
|
+
secrets = migrateSecrets(secrets);
|
|
104
|
+
writeSecrets(secrets);
|
|
105
|
+
} else {
|
|
106
|
+
const salt = randomBytes(32);
|
|
107
|
+
secrets._salt = salt.toString('base64');
|
|
108
|
+
writeSecrets(secrets);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const salt = Buffer.from(secrets._salt, 'base64');
|
|
113
|
+
const key = deriveKey(salt);
|
|
114
|
+
return { key, secrets };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function storeSecret(connexionId, plaintext) {
|
|
118
|
+
if (!validateApiKey(plaintext)) {
|
|
119
|
+
throw new Error(`Invalid key: must be at least ${MIN_API_KEY_LENGTH} characters`);
|
|
120
|
+
}
|
|
121
|
+
const { key, secrets } = getKeyAndSecrets();
|
|
122
|
+
secrets[connexionId] = encrypt(plaintext, key);
|
|
123
|
+
writeSecrets(secrets);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getSecret(connexionId) {
|
|
127
|
+
const { key, secrets } = getKeyAndSecrets();
|
|
128
|
+
if (!secrets[connexionId]) return null;
|
|
129
|
+
try { return decrypt(secrets[connexionId], key); }
|
|
130
|
+
catch (_e) { /* fallback: decryption failed */ return null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function removeSecret(connexionId) {
|
|
134
|
+
const { secrets } = getKeyAndSecrets();
|
|
135
|
+
delete secrets[connexionId];
|
|
136
|
+
writeSecrets(secrets);
|
|
137
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const SERVICE_IDS = ['notes', 'kairos'];
|
|
5
|
+
|
|
6
|
+
export const SERVICE_LABELS = {
|
|
7
|
+
notes: 'NOTES / CR \u2014 Extraction post-hook',
|
|
8
|
+
kairos: 'KAIROS ROUTER \u2014 Routeur situationnel',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const SERVICE_DEFAULTS = {
|
|
12
|
+
notes: { timeout: 8, fallback_timeout: 5 },
|
|
13
|
+
kairos: { timeout: 5, fallback_timeout: 3 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function readServices(root) {
|
|
17
|
+
const file = join(root, '.nemesis', 'services.json');
|
|
18
|
+
if (!existsSync(file)) return { services: {} };
|
|
19
|
+
try { return JSON.parse(readFileSync(file, 'utf-8')); }
|
|
20
|
+
catch (_e) { /* fallback: corrupted services file */ return { services: {} }; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeServices(root, data) {
|
|
24
|
+
const file = join(root, '.nemesis', 'services.json');
|
|
25
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assignService(root, serviceId, opts) {
|
|
29
|
+
const { connexionId, modelOverride, fallback, timeout } = opts;
|
|
30
|
+
const data = readServices(root);
|
|
31
|
+
const defaults = SERVICE_DEFAULTS[serviceId] || { timeout: 5 };
|
|
32
|
+
data.services[serviceId] = {
|
|
33
|
+
connexion_id: connexionId,
|
|
34
|
+
model_override: modelOverride || null,
|
|
35
|
+
fallback: fallback || [],
|
|
36
|
+
timeout: timeout || defaults.timeout,
|
|
37
|
+
};
|
|
38
|
+
writeServices(root, data);
|
|
39
|
+
return data.services[serviceId];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getServiceConfig(root, serviceId) {
|
|
43
|
+
const data = readServices(root);
|
|
44
|
+
return data.services[serviceId] || null;
|
|
45
|
+
}
|