@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,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
+ }