@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,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nemesis MCP Client — wraps 8 Nemesis reference tools.
|
|
3
|
+
* Tries HTTP remote first, falls back to local registre JSON files.
|
|
4
|
+
* In-memory cache with configurable TTL.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { debug } from '../core/logger.js';
|
|
11
|
+
|
|
12
|
+
/** Default registre directory (relative to project root) */
|
|
13
|
+
const DEFAULT_REGISTRE_DIR = '.owner/spec-in/registre-gestion-de-projet';
|
|
14
|
+
|
|
15
|
+
/** Default TTL for cache entries (5 minutes) */
|
|
16
|
+
const DEFAULT_CACHE_TTL = 5 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Bloc ID to file mapping */
|
|
19
|
+
const BLOC_FILES = {
|
|
20
|
+
B00: 'B00_gouvernance.json',
|
|
21
|
+
B05: 'B05_equipe.json',
|
|
22
|
+
B10: 'B10_mission_contract.json',
|
|
23
|
+
B15: 'B15_execution.json',
|
|
24
|
+
B20: 'B20_odm.json',
|
|
25
|
+
B25: 'B25_controle_evidence.json',
|
|
26
|
+
B30: 'B30_qa.json',
|
|
27
|
+
B35: 'B35_transactions.json',
|
|
28
|
+
B40: 'B40_validation.json',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Situation → bloc mapping (from NAVIGATION.json quick_routes) */
|
|
32
|
+
const SITUATION_BLOC_MAP = {
|
|
33
|
+
onboarding: 'B05',
|
|
34
|
+
review: 'B25',
|
|
35
|
+
architecture: 'B10',
|
|
36
|
+
implementation: 'B15',
|
|
37
|
+
documentation: 'B20',
|
|
38
|
+
audit: 'B30',
|
|
39
|
+
debugging: 'B15',
|
|
40
|
+
validation: 'B40',
|
|
41
|
+
dispatch: 'B35',
|
|
42
|
+
qa: 'B30',
|
|
43
|
+
general: 'B00',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Simple in-memory cache with TTL.
|
|
48
|
+
*/
|
|
49
|
+
function createCache(ttl = DEFAULT_CACHE_TTL) {
|
|
50
|
+
const store = new Map();
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
get(key) {
|
|
54
|
+
const entry = store.get(key);
|
|
55
|
+
if (!entry) return undefined;
|
|
56
|
+
if (Date.now() - entry.ts > ttl) {
|
|
57
|
+
store.delete(key);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return entry.value;
|
|
61
|
+
},
|
|
62
|
+
set(key, value) {
|
|
63
|
+
store.set(key, { value, ts: Date.now() });
|
|
64
|
+
},
|
|
65
|
+
clear() {
|
|
66
|
+
store.clear();
|
|
67
|
+
},
|
|
68
|
+
size() {
|
|
69
|
+
return store.size;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load Nemesis config from ~/.nemesis/config.json.
|
|
76
|
+
*/
|
|
77
|
+
function loadNemesisConfig() {
|
|
78
|
+
const configFile = join(homedir(), '.nemesis', 'config.json');
|
|
79
|
+
if (!existsSync(configFile)) return {};
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
82
|
+
} catch (e) {
|
|
83
|
+
debug(`loadNemesisConfig: ${e.message}`);
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a bloc JSON file from the local registre.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} registreDir - absolute path to registre directory
|
|
92
|
+
* @param {string} filename - e.g. "B00_gouvernance.json"
|
|
93
|
+
* @returns {object|null}
|
|
94
|
+
*/
|
|
95
|
+
function readBlocFile(registreDir, filename) {
|
|
96
|
+
const filepath = join(registreDir, filename);
|
|
97
|
+
if (!existsSync(filepath)) return null;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(readFileSync(filepath, 'utf-8'));
|
|
100
|
+
} catch (e) {
|
|
101
|
+
debug(`readBlocFile ${filename}: ${e.message}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a Nemesis MCP client.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @param {string} [opts.baseUrl] - Nemesis MCP server URL
|
|
111
|
+
* @param {string} [opts.projectRoot] - project root for local fallback
|
|
112
|
+
* @param {string} [opts.registreDir] - override registre directory (absolute)
|
|
113
|
+
* @param {number} [opts.timeout] - HTTP timeout in ms (default 5000)
|
|
114
|
+
* @param {number} [opts.cacheTtl] - cache TTL in ms (default 5min)
|
|
115
|
+
* @returns {object} client with 8 tool methods
|
|
116
|
+
*/
|
|
117
|
+
export function createNemesisClient(opts = {}) {
|
|
118
|
+
const config = loadNemesisConfig();
|
|
119
|
+
const {
|
|
120
|
+
baseUrl = config.nemesis_url || process.env.NEMESIS_URL || 'https://nemesis.arkalabs.app:3003',
|
|
121
|
+
projectRoot = process.cwd(),
|
|
122
|
+
timeout = 5000,
|
|
123
|
+
cacheTtl = DEFAULT_CACHE_TTL,
|
|
124
|
+
} = opts;
|
|
125
|
+
|
|
126
|
+
const registreDir = opts.registreDir || join(projectRoot, DEFAULT_REGISTRE_DIR);
|
|
127
|
+
const cache = createCache(cacheTtl);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* HTTP request to the Nemesis MCP server.
|
|
131
|
+
* Returns null on any failure (timeout, network, 4xx, 5xx).
|
|
132
|
+
*/
|
|
133
|
+
async function remoteRequest(path) {
|
|
134
|
+
try {
|
|
135
|
+
const url = `${baseUrl}${path}`;
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
138
|
+
const res = await fetch(url, {
|
|
139
|
+
method: 'GET',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
});
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
if (!res.ok) return null;
|
|
145
|
+
const json = await res.json();
|
|
146
|
+
return json?.data ?? json;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
debug(`remoteRequest: ${e.message}`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Try remote first, then local fallback. Cache the result.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} cacheKey
|
|
157
|
+
* @param {string} remotePath - HTTP path
|
|
158
|
+
* @param {function} localFallback - () => data | null
|
|
159
|
+
* @returns {Promise<object|null>}
|
|
160
|
+
*/
|
|
161
|
+
async function fetchWithFallback(cacheKey, remotePath, localFallback) {
|
|
162
|
+
const cached = cache.get(cacheKey);
|
|
163
|
+
if (cached !== undefined) return cached;
|
|
164
|
+
|
|
165
|
+
let data = await remoteRequest(remotePath);
|
|
166
|
+
let source = 'remote';
|
|
167
|
+
|
|
168
|
+
if (!data) {
|
|
169
|
+
data = localFallback();
|
|
170
|
+
source = 'local';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (data) {
|
|
174
|
+
data._source = source;
|
|
175
|
+
cache.set(cacheKey, data);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return data;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
baseUrl,
|
|
183
|
+
registreDir,
|
|
184
|
+
cache,
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Navigate — given a situation, return the relevant bloc + rules.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} situation - e.g. "onboarding", "review", "implementation"
|
|
190
|
+
* @returns {Promise<{ bloc: string, blocData: object, rules: object[] } | null>}
|
|
191
|
+
*/
|
|
192
|
+
async navigate(situation) {
|
|
193
|
+
const blocId = SITUATION_BLOC_MAP[situation] || 'B00';
|
|
194
|
+
return fetchWithFallback(
|
|
195
|
+
`navigate:${situation}`,
|
|
196
|
+
`/nemesis/navigate?situation=${encodeURIComponent(situation)}`,
|
|
197
|
+
() => {
|
|
198
|
+
const filename = BLOC_FILES[blocId];
|
|
199
|
+
if (!filename) return null;
|
|
200
|
+
const blocData = readBlocFile(registreDir, filename);
|
|
201
|
+
if (!blocData) return null;
|
|
202
|
+
return {
|
|
203
|
+
bloc: blocId,
|
|
204
|
+
situation,
|
|
205
|
+
atoms: blocData.atoms || [],
|
|
206
|
+
count: blocData.count || 0,
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Bloc — return all atoms for a given bloc.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} blocId - e.g. "B00", "B05", "B10"
|
|
216
|
+
* @returns {Promise<{ bloc: string, atoms: object[], count: number } | null>}
|
|
217
|
+
*/
|
|
218
|
+
async bloc(blocId) {
|
|
219
|
+
return fetchWithFallback(
|
|
220
|
+
`bloc:${blocId}`,
|
|
221
|
+
`/nemesis/bloc/${encodeURIComponent(blocId)}`,
|
|
222
|
+
() => {
|
|
223
|
+
const filename = BLOC_FILES[blocId];
|
|
224
|
+
if (!filename) return null;
|
|
225
|
+
const data = readBlocFile(registreDir, filename);
|
|
226
|
+
if (!data) return null;
|
|
227
|
+
return {
|
|
228
|
+
bloc: data.bloc || blocId,
|
|
229
|
+
atoms: data.atoms || [],
|
|
230
|
+
count: data.count || 0,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Rules — return all rules, optionally filtered by priority.
|
|
238
|
+
*
|
|
239
|
+
* @param {object} [filter]
|
|
240
|
+
* @param {string} [filter.priority] - e.g. "REQUIRED", "FORBIDDEN", "BLOCKING"
|
|
241
|
+
* @returns {Promise<{ rules: object[], count: number } | null>}
|
|
242
|
+
*/
|
|
243
|
+
async rules(filter = {}) {
|
|
244
|
+
const priorityParam = filter.priority ? `?priority=${encodeURIComponent(filter.priority)}` : '';
|
|
245
|
+
return fetchWithFallback(
|
|
246
|
+
`rules:${filter.priority || 'all'}`,
|
|
247
|
+
`/nemesis/rules${priorityParam}`,
|
|
248
|
+
() => {
|
|
249
|
+
const allRules = [];
|
|
250
|
+
for (const filename of Object.values(BLOC_FILES)) {
|
|
251
|
+
const data = readBlocFile(registreDir, filename);
|
|
252
|
+
if (data?.atoms) {
|
|
253
|
+
for (const atom of data.atoms) {
|
|
254
|
+
if (!filter.priority || atom.priority === filter.priority) {
|
|
255
|
+
allRules.push(atom);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { rules: allRules, count: allRules.length };
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Flowmap — return the full FLOWMAP (steps + exceptions).
|
|
267
|
+
*
|
|
268
|
+
* @returns {Promise<{ steps: object[], exception_steps: object[] } | null>}
|
|
269
|
+
*/
|
|
270
|
+
async flowmap() {
|
|
271
|
+
return fetchWithFallback(
|
|
272
|
+
'flowmap',
|
|
273
|
+
'/nemesis/flowmap',
|
|
274
|
+
() => {
|
|
275
|
+
const data = readBlocFile(registreDir, 'FLOWMAP.json');
|
|
276
|
+
if (!data) return null;
|
|
277
|
+
return {
|
|
278
|
+
steps: data.steps || [],
|
|
279
|
+
exception_steps: data.exception_steps || [],
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Roles — return the 5 roles + team rules from B05.
|
|
287
|
+
*
|
|
288
|
+
* @returns {Promise<{ roles: object[], rules: object[] } | null>}
|
|
289
|
+
*/
|
|
290
|
+
async roles() {
|
|
291
|
+
return fetchWithFallback(
|
|
292
|
+
'roles',
|
|
293
|
+
'/nemesis/roles',
|
|
294
|
+
() => {
|
|
295
|
+
const data = readBlocFile(registreDir, 'B05_equipe.json');
|
|
296
|
+
if (!data) return null;
|
|
297
|
+
const atoms = data.atoms || [];
|
|
298
|
+
const roles = atoms.filter(a => a.sousFamille === 'ROLE');
|
|
299
|
+
const rules = atoms.filter(a => a.sousFamille === 'EQUIPE');
|
|
300
|
+
return { roles, rules };
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Transactions — return all transaction rules from B35.
|
|
307
|
+
*
|
|
308
|
+
* @returns {Promise<{ atoms: object[], count: number } | null>}
|
|
309
|
+
*/
|
|
310
|
+
async transactions() {
|
|
311
|
+
return fetchWithFallback(
|
|
312
|
+
'transactions',
|
|
313
|
+
'/nemesis/transactions',
|
|
314
|
+
() => {
|
|
315
|
+
const data = readBlocFile(registreDir, 'B35_transactions.json');
|
|
316
|
+
if (!data) return null;
|
|
317
|
+
return { atoms: data.atoms || [], count: data.count || 0 };
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* QA — return all QA rules from B30.
|
|
324
|
+
*
|
|
325
|
+
* @returns {Promise<{ atoms: object[], count: number } | null>}
|
|
326
|
+
*/
|
|
327
|
+
async qa() {
|
|
328
|
+
return fetchWithFallback(
|
|
329
|
+
'qa',
|
|
330
|
+
'/nemesis/qa',
|
|
331
|
+
() => {
|
|
332
|
+
const data = readBlocFile(registreDir, 'B30_qa.json');
|
|
333
|
+
if (!data) return null;
|
|
334
|
+
return { atoms: data.atoms || [], count: data.count || 0 };
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Situations — return situation detection patterns.
|
|
341
|
+
*
|
|
342
|
+
* @returns {Promise<{ situations: object[] } | null>}
|
|
343
|
+
*/
|
|
344
|
+
async situations() {
|
|
345
|
+
return fetchWithFallback(
|
|
346
|
+
'situations',
|
|
347
|
+
'/nemesis/situations',
|
|
348
|
+
() => ({
|
|
349
|
+
situations: Object.entries(SITUATION_BLOC_MAP).map(([situation, blocId]) => ({
|
|
350
|
+
situation,
|
|
351
|
+
bloc: blocId,
|
|
352
|
+
blocFile: BLOC_FILES[blocId] || null,
|
|
353
|
+
})),
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Ping — check if the Nemesis MCP server is reachable.
|
|
360
|
+
*
|
|
361
|
+
* @returns {Promise<{ ok: boolean, latency: number, error?: string }>}
|
|
362
|
+
*/
|
|
363
|
+
async ping() {
|
|
364
|
+
const start = Date.now();
|
|
365
|
+
try {
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
368
|
+
const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
return { ok: res.ok, latency: Date.now() - start };
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return { ok: false, latency: Date.now() - start, error: err.message };
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Re-export for testability
|
|
379
|
+
export { BLOC_FILES, SITUATION_BLOC_MAP, DEFAULT_CACHE_TTL, createCache };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { executeWithFallback } from './fallback-engine.js';
|
|
3
|
+
import { callLlmApi } from './llm-client.js';
|
|
4
|
+
import { debug } from '../core/logger.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Send a prompt to Claude via `claude -p` (stateless, no resume).
|
|
8
|
+
* @param {string} prompt
|
|
9
|
+
* @param {object} opts - { cwd, model, timeout }
|
|
10
|
+
* @returns {Promise<string>} response text
|
|
11
|
+
*/
|
|
12
|
+
export async function callSession(_serviceId, prompt, opts = {}) {
|
|
13
|
+
const timeoutMs = (opts.timeout || 30) * 1000;
|
|
14
|
+
|
|
15
|
+
const args = [
|
|
16
|
+
'-p', prompt,
|
|
17
|
+
'--output-format', 'json',
|
|
18
|
+
'--dangerously-skip-permissions',
|
|
19
|
+
];
|
|
20
|
+
if (opts.model) args.push('--model', opts.model);
|
|
21
|
+
|
|
22
|
+
const result = execFileSync('claude', args, {
|
|
23
|
+
cwd: opts.cwd || process.cwd(),
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
timeout: timeoutMs,
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
env: { ...process.env },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Parse response
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(result);
|
|
33
|
+
return parsed.result || parsed.text || result;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
debug(`callSession JSON parse: ${e.message}`);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a service caller for NoteWriter integration.
|
|
42
|
+
* Returns async (systemPrompt, userPrompt) => responseText
|
|
43
|
+
* Uses fallback engine for automatic retry across configured connexions.
|
|
44
|
+
* @param {string} projectRoot - Project root directory
|
|
45
|
+
* @param {string} serviceId - Service identifier (notes, kairos)
|
|
46
|
+
* @returns {Function} async (systemPrompt, userPrompt) => string
|
|
47
|
+
*/
|
|
48
|
+
export function createServiceCaller(projectRoot, serviceId) {
|
|
49
|
+
const caller = async (systemPrompt, userPrompt) => {
|
|
50
|
+
const { result, degraded, connexion_used, is_fallback } = await executeWithFallback(projectRoot, serviceId, async (ctx) => {
|
|
51
|
+
if (ctx.callService) {
|
|
52
|
+
// cli_tools — stateless call (combined prompt)
|
|
53
|
+
return ctx.callService(`${systemPrompt}\n\n---\n\n${userPrompt}`);
|
|
54
|
+
}
|
|
55
|
+
// API provider — direct HTTP call
|
|
56
|
+
return callLlmApi(ctx.connexion, ctx.credential, ctx.model, systemPrompt, userPrompt);
|
|
57
|
+
});
|
|
58
|
+
if (degraded || result === null) {
|
|
59
|
+
throw new Error('Aucun provider LLM disponible');
|
|
60
|
+
}
|
|
61
|
+
caller.lastProvider = connexion_used || null;
|
|
62
|
+
caller.wasFallback = is_fallback || false;
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
caller.lastProvider = null;
|
|
66
|
+
caller.wasFallback = false;
|
|
67
|
+
return caller;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Legacy exports — no-op (no sessions to manage)
|
|
71
|
+
export function getOrCreateSession() { return null; }
|
|
72
|
+
export function stopSession() {}
|
|
73
|
+
export function stopAllSessions() {}
|
|
74
|
+
export function getActiveSessions() { return {}; }
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { debug } from '../core/logger.js';
|
|
5
|
+
|
|
6
|
+
const SYNC_STATE_FILE = '.nemesis-sync-state.json';
|
|
7
|
+
|
|
8
|
+
const TYPE_PATTERNS = [
|
|
9
|
+
{ pattern: /^ODM-/, type: 'ODM' },
|
|
10
|
+
{ pattern: /^CR-/, type: 'CR' },
|
|
11
|
+
{ pattern: /^MCT-/, type: 'MCT' },
|
|
12
|
+
{ pattern: /^TXN-/, type: 'TXN' },
|
|
13
|
+
{ pattern: /^CONTRIB-/, type: 'CONTRIB' },
|
|
14
|
+
{ pattern: /^REGISTRY-/, type: 'REGISTRY' },
|
|
15
|
+
{ pattern: /^DEC-/, type: 'DECISION' },
|
|
16
|
+
{ pattern: /^INTV-/, type: 'INTERVENTION' },
|
|
17
|
+
{ pattern: /^CONTEXT_PROJECT_/, type: 'CONTEXT' },
|
|
18
|
+
{ pattern: /^NOTE-/, type: 'NOTE' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Scan a directory recursively, collect JSON files (ignore template_* and legacy_*).
|
|
23
|
+
*/
|
|
24
|
+
export function scanDir(hcmDir) {
|
|
25
|
+
const results = [];
|
|
26
|
+
|
|
27
|
+
function walk(dir) {
|
|
28
|
+
if (!existsSync(dir)) return;
|
|
29
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
walk(join(dir, entry.name));
|
|
32
|
+
} else if (
|
|
33
|
+
entry.name.endsWith('.json') &&
|
|
34
|
+
!entry.name.startsWith('template_') &&
|
|
35
|
+
!entry.name.startsWith('legacy_') &&
|
|
36
|
+
entry.name !== SYNC_STATE_FILE
|
|
37
|
+
) {
|
|
38
|
+
results.push({
|
|
39
|
+
path: join(dir, entry.name),
|
|
40
|
+
name: entry.name,
|
|
41
|
+
relative: relative(hcmDir, join(dir, entry.name)),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
walk(hcmDir);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Determine the HCM type from a filename.
|
|
53
|
+
*/
|
|
54
|
+
export function matchPattern(filename) {
|
|
55
|
+
for (const { pattern, type } of TYPE_PATTERNS) {
|
|
56
|
+
if (pattern.test(filename)) return type;
|
|
57
|
+
}
|
|
58
|
+
return 'UNKNOWN';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compute SHA-256 content hash of a file.
|
|
63
|
+
*/
|
|
64
|
+
export function computeHash(filepath) {
|
|
65
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
66
|
+
return createHash('sha256').update(content).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load sync state (previously synced hashes).
|
|
71
|
+
*/
|
|
72
|
+
export function loadSyncState(hcmDir) {
|
|
73
|
+
const stateFile = join(hcmDir, SYNC_STATE_FILE);
|
|
74
|
+
if (!existsSync(stateFile)) return {};
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
debug(`loadSyncState: ${e.message}`);
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Save sync state.
|
|
85
|
+
*/
|
|
86
|
+
export function saveSyncState(hcmDir, state) {
|
|
87
|
+
const stateFile = join(hcmDir, SYNC_STATE_FILE);
|
|
88
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Diff a file against the last known hash.
|
|
93
|
+
*/
|
|
94
|
+
export function diffHash(filepath, syncState) {
|
|
95
|
+
const currentHash = computeHash(filepath);
|
|
96
|
+
const previousHash = syncState[filepath] || null;
|
|
97
|
+
return {
|
|
98
|
+
changed: currentHash !== previousHash,
|
|
99
|
+
currentHash,
|
|
100
|
+
previousHash,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sync a single file to HCM API.
|
|
106
|
+
*/
|
|
107
|
+
export async function syncToHcm(file, hcmClient) {
|
|
108
|
+
const content = readFileSync(file.path, 'utf-8');
|
|
109
|
+
let data;
|
|
110
|
+
try {
|
|
111
|
+
data = JSON.parse(content);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
debug(`syncToHcm parse ${file.name}: ${e.message}`);
|
|
114
|
+
return { ok: false, error: 'Invalid JSON' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const type = matchPattern(file.name);
|
|
118
|
+
const nodeId = extractNodeId(data, file.name, type);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await hcmClient.deposit({
|
|
122
|
+
type,
|
|
123
|
+
label: nodeId,
|
|
124
|
+
id: `${type.toLowerCase()}:${nodeId}`,
|
|
125
|
+
data: { content: JSON.stringify(data), source_file: file.relative },
|
|
126
|
+
});
|
|
127
|
+
return { ok: true, nodeId, type };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { ok: false, error: err.message };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Full sync — scan, match, diff, sync modified files.
|
|
135
|
+
*/
|
|
136
|
+
export async function fullSync(hcmDir, hcmClient) {
|
|
137
|
+
const files = scanDir(hcmDir);
|
|
138
|
+
const syncState = loadSyncState(hcmDir);
|
|
139
|
+
const results = { synced: 0, unchanged: 0, errors: 0, total: files.length, details: [] };
|
|
140
|
+
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
const diff = diffHash(file.path, syncState);
|
|
143
|
+
if (!diff.changed) {
|
|
144
|
+
results.unchanged++;
|
|
145
|
+
results.details.push({ file: file.name, status: 'unchanged' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const syncResult = await syncToHcm(file, hcmClient);
|
|
150
|
+
if (syncResult.ok) {
|
|
151
|
+
results.synced++;
|
|
152
|
+
syncState[file.path] = diff.currentHash;
|
|
153
|
+
results.details.push({ file: file.name, status: 'synced', type: syncResult.type });
|
|
154
|
+
} else {
|
|
155
|
+
results.errors++;
|
|
156
|
+
results.details.push({ file: file.name, status: 'error', error: syncResult.error });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
saveSyncState(hcmDir, syncState);
|
|
161
|
+
return results;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractNodeId(data, filename, type) {
|
|
165
|
+
switch (type) {
|
|
166
|
+
case 'ODM': return data.odm_meta?.odm_id || filename.replace('.json', '');
|
|
167
|
+
case 'CR': return data.metadata?.odm_id || filename.replace('.json', '');
|
|
168
|
+
case 'MCT': return data.contract_meta?.id || filename.replace('.json', '');
|
|
169
|
+
case 'TXN': return data.transaction_meta?.txn_id || filename.replace('.json', '');
|
|
170
|
+
case 'CONTRIB': return data.contributor_meta?.contributor_id || filename.replace('.json', '');
|
|
171
|
+
case 'REGISTRY': return data.registry_meta?.registry_id || filename.replace('.json', '');
|
|
172
|
+
case 'DECISION': return data.decision_meta?.decision_id || filename.replace('.json', '');
|
|
173
|
+
case 'INTERVENTION': return data.intervention_meta?.intervention_id || filename.replace('.json', '');
|
|
174
|
+
case 'CONTEXT': return data.project_meta?.project_id || filename.replace('.json', '');
|
|
175
|
+
case 'NOTE': return data.note_meta?.note_id || filename.replace('.json', '');
|
|
176
|
+
default: return filename.replace('.json', '');
|
|
177
|
+
}
|
|
178
|
+
}
|