@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,275 @@
1
+ /**
2
+ * Inbox — manage human documents pipeline.
3
+ * Structure: .nemesis/inbox/{pending,processing,done}/
4
+ * Each document has a metadata JSON + the original file content.
5
+ *
6
+ * Lifecycle: pending → processing (dispatch launched) → done (MC created)
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync, statSync } from 'node:fs';
10
+ import { join, basename, extname } from 'node:path';
11
+
12
+ const INBOX_DIR = '.nemesis/inbox';
13
+ const SUBDIRS = ['pending', 'processing', 'done'];
14
+
15
+ let _idCounter = 0;
16
+ function nextDocId() {
17
+ const ts = Date.now();
18
+ return `DOC-${ts}-${String(++_idCounter).padStart(3, '0')}`;
19
+ }
20
+
21
+ /**
22
+ * Ensure inbox directories exist.
23
+ * @param {string} root - project root
24
+ */
25
+ export function ensureInboxDirs(root) {
26
+ for (const sub of SUBDIRS) {
27
+ const dir = join(root, INBOX_DIR, sub);
28
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Add a document to the inbox (copies to pending/).
34
+ *
35
+ * @param {string} root - project root
36
+ * @param {string} filePath - absolute path to the document file
37
+ * @param {object} [opts]
38
+ * @param {string} [opts.title] - human title
39
+ * @param {string} [opts.source] - source (e.g. "email", "slack", "manual")
40
+ * @returns {{ id: string, metadataPath: string, contentPath: string }}
41
+ */
42
+ export function addDocument(root, filePath, opts = {}) {
43
+ ensureInboxDirs(root);
44
+
45
+ if (!existsSync(filePath)) {
46
+ throw new Error(`Fichier introuvable : ${filePath}`);
47
+ }
48
+
49
+ const ext = extname(filePath);
50
+ const name = basename(filePath, ext);
51
+ const id = nextDocId();
52
+ const contentFilename = `${id}${ext}`;
53
+ const metaFilename = `${id}.meta.json`;
54
+
55
+ const pendingDir = join(root, INBOX_DIR, 'pending');
56
+ const contentPath = join(pendingDir, contentFilename);
57
+ const metadataPath = join(pendingDir, metaFilename);
58
+
59
+ // Copy file content
60
+ copyFileSync(filePath, contentPath);
61
+
62
+ // Write metadata
63
+ const metadata = {
64
+ id,
65
+ title: opts.title || name,
66
+ source: opts.source || 'manual',
67
+ original_path: filePath,
68
+ original_filename: basename(filePath),
69
+ status: 'pending',
70
+ created_at: new Date().toISOString(),
71
+ content_file: contentFilename,
72
+ };
73
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
74
+
75
+ return { id, metadataPath, contentPath };
76
+ }
77
+
78
+ /**
79
+ * Add a document from inline text (no file copy).
80
+ *
81
+ * @param {string} root - project root
82
+ * @param {string} text - document text content
83
+ * @param {object} [opts]
84
+ * @param {string} [opts.title] - human title
85
+ * @param {string} [opts.source] - source
86
+ * @returns {{ id: string, metadataPath: string, contentPath: string }}
87
+ */
88
+ export function addDocumentFromText(root, text, opts = {}) {
89
+ ensureInboxDirs(root);
90
+
91
+ const id = nextDocId();
92
+ const contentFilename = `${id}.md`;
93
+ const metaFilename = `${id}.meta.json`;
94
+
95
+ const pendingDir = join(root, INBOX_DIR, 'pending');
96
+ const contentPath = join(pendingDir, contentFilename);
97
+ const metadataPath = join(pendingDir, metaFilename);
98
+
99
+ writeFileSync(contentPath, text, 'utf-8');
100
+
101
+ const metadata = {
102
+ id,
103
+ title: opts.title || `Document ${id}`,
104
+ source: opts.source || 'inline',
105
+ original_path: null,
106
+ original_filename: null,
107
+ status: 'pending',
108
+ created_at: new Date().toISOString(),
109
+ content_file: contentFilename,
110
+ };
111
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
112
+
113
+ return { id, metadataPath, contentPath };
114
+ }
115
+
116
+ /**
117
+ * List documents in a given status directory.
118
+ *
119
+ * @param {string} root - project root
120
+ * @param {'pending'|'processing'|'done'} status
121
+ * @returns {Array<{ id: string, title: string, source: string, status: string, created_at: string, content_file: string }>}
122
+ */
123
+ export function listDocuments(root, status = 'pending') {
124
+ const dir = join(root, INBOX_DIR, status);
125
+ if (!existsSync(dir)) return [];
126
+
127
+ const metaFiles = readdirSync(dir).filter(f => f.endsWith('.meta.json'));
128
+ const docs = [];
129
+
130
+ for (const file of metaFiles) {
131
+ try {
132
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
133
+ docs.push(data);
134
+ } catch (_e) { /* fallback: skip corrupted meta */ }
135
+ }
136
+
137
+ // Sort by created_at desc
138
+ docs.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
139
+ return docs;
140
+ }
141
+
142
+ /**
143
+ * Read a document's content.
144
+ *
145
+ * @param {string} root - project root
146
+ * @param {string} docId - document ID (e.g. "DOC-1710496800000")
147
+ * @param {'pending'|'processing'|'done'} [status]
148
+ * @returns {{ metadata: object, content: string } | null}
149
+ */
150
+ export function readDocument(root, docId, status) {
151
+ const statuses = status ? [status] : SUBDIRS;
152
+
153
+ for (const s of statuses) {
154
+ const dir = join(root, INBOX_DIR, s);
155
+ const metaPath = join(dir, `${docId}.meta.json`);
156
+ if (!existsSync(metaPath)) continue;
157
+
158
+ try {
159
+ const metadata = JSON.parse(readFileSync(metaPath, 'utf-8'));
160
+ const contentPath = join(dir, metadata.content_file);
161
+ const content = existsSync(contentPath) ? readFileSync(contentPath, 'utf-8') : '';
162
+ return { metadata, content };
163
+ } catch (_e) { /* fallback: unreadable document */ continue; }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Move a document between status directories.
170
+ *
171
+ * @param {string} root
172
+ * @param {string} docId
173
+ * @param {'pending'|'processing'|'done'} fromStatus
174
+ * @param {'pending'|'processing'|'done'} toStatus
175
+ * @param {object} [extraMeta] - extra metadata to merge
176
+ * @returns {boolean}
177
+ */
178
+ export function moveDocument(root, docId, fromStatus, toStatus, extraMeta = {}) {
179
+ ensureInboxDirs(root);
180
+
181
+ const fromDir = join(root, INBOX_DIR, fromStatus);
182
+ const toDir = join(root, INBOX_DIR, toStatus);
183
+ const metaPath = join(fromDir, `${docId}.meta.json`);
184
+
185
+ if (!existsSync(metaPath)) return false;
186
+
187
+ try {
188
+ const metadata = JSON.parse(readFileSync(metaPath, 'utf-8'));
189
+ const contentFile = metadata.content_file;
190
+
191
+ // Move content file
192
+ const fromContent = join(fromDir, contentFile);
193
+ const toContent = join(toDir, contentFile);
194
+ if (existsSync(fromContent)) {
195
+ renameSync(fromContent, toContent);
196
+ }
197
+
198
+ // Update + move metadata
199
+ metadata.status = toStatus;
200
+ metadata[`${toStatus}_at`] = new Date().toISOString();
201
+ Object.assign(metadata, extraMeta);
202
+
203
+ const toMeta = join(toDir, `${docId}.meta.json`);
204
+ writeFileSync(toMeta, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
205
+
206
+ // Remove old meta
207
+ try { unlinkSync(metaPath); } catch (_e) { /* fallback: already moved */ }
208
+
209
+ return true;
210
+ } catch (_e) { /* fallback: move failed */ return false; }
211
+ }
212
+
213
+ /**
214
+ * Mark a document as processing (dispatch launched).
215
+ *
216
+ * @param {string} root
217
+ * @param {string} docId
218
+ * @param {object} [dispatchInfo] - { txnId, agentName }
219
+ * @returns {boolean}
220
+ */
221
+ export function markProcessing(root, docId, dispatchInfo = {}) {
222
+ return moveDocument(root, docId, 'pending', 'processing', {
223
+ dispatch_txn_id: dispatchInfo.txnId || null,
224
+ dispatch_agent: dispatchInfo.agentName || null,
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Mark a document as done (MC created).
230
+ *
231
+ * @param {string} root
232
+ * @param {string} docId
233
+ * @param {object} [mcInfo] - { missionId }
234
+ * @returns {boolean}
235
+ */
236
+ export function markDone(root, docId, mcInfo = {}) {
237
+ return moveDocument(root, docId, 'processing', 'done', {
238
+ mission_id: mcInfo.missionId || null,
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Cleanup done documents older than maxDays.
244
+ *
245
+ * @param {string} root
246
+ * @param {number} [maxDays=30]
247
+ * @returns {number} count of cleaned documents
248
+ */
249
+ export function cleanupDone(root, maxDays = 30) {
250
+ const doneDir = join(root, INBOX_DIR, 'done');
251
+ if (!existsSync(doneDir)) return 0;
252
+
253
+ const cleanAll = maxDays <= 0;
254
+ const cutoff = cleanAll ? Infinity : Date.now() - (maxDays * 24 * 60 * 60 * 1000);
255
+ let count = 0;
256
+
257
+ const metaFiles = readdirSync(doneDir).filter(f => f.endsWith('.meta.json'));
258
+ for (const file of metaFiles) {
259
+ const filepath = join(doneDir, file);
260
+ try {
261
+ const stat = statSync(filepath);
262
+ if (cleanAll || stat.mtimeMs < cutoff) {
263
+ const metadata = JSON.parse(readFileSync(filepath, 'utf-8'));
264
+ // Remove content file
265
+ const contentPath = join(doneDir, metadata.content_file);
266
+ try { unlinkSync(contentPath); } catch (_e) { /* fallback: already removed */ }
267
+ // Remove meta
268
+ unlinkSync(filepath);
269
+ count++;
270
+ }
271
+ } catch (_e) { /* fallback: skip corrupted entry */ }
272
+ }
273
+
274
+ return count;
275
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Minimal logger — writes to stderr to avoid polluting stdout (reserved for hooks).
3
+ * Activate debug output via NEMESIS_VERBOSE=1 or --verbose flag.
4
+ */
5
+
6
+ export function isVerbose() {
7
+ return process.env.NEMESIS_VERBOSE === '1' || process.argv.includes('--verbose');
8
+ }
9
+
10
+ export function warn(msg) {
11
+ process.stderr.write(`[nemesis:warn] ${msg}\n`);
12
+ }
13
+
14
+ export function error(msg) {
15
+ process.stderr.write(`[nemesis:error] ${msg}\n`);
16
+ }
17
+
18
+ export function debug(msg) {
19
+ if (isVerbose()) process.stderr.write(`[nemesis:debug] ${msg}\n`);
20
+ }
@@ -0,0 +1,109 @@
1
+ import { writeFileSync, existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { loadTemplate, mergeTemplate } from './templates.js';
4
+
5
+ /**
6
+ * Init a Mission Contract — creates a DRAFT MCT-*.json.
7
+ */
8
+ export function initMission(id, opts = {}) {
9
+ const { title = '', summary = '', root = process.cwd(), projectId = '', owner = 'PM' } = opts;
10
+
11
+ const hcmDir = join(root, '.nemesis', 'HCM');
12
+ const contractsDir = join(hcmDir, 'contracts');
13
+
14
+ if (!existsSync(contractsDir)) {
15
+ throw new Error('.nemesis/HCM/contracts/ introuvable. Lancez "nemesis init" d\'abord.');
16
+ }
17
+
18
+ const template = loadTemplate('mission-contract', root);
19
+ const contract = mergeTemplate(template, {
20
+ contract_meta: {
21
+ id,
22
+ mission_id: `MSN-${id.replace('MCT-', '')}`,
23
+ project_id: projectId,
24
+ status: 'DRAFT',
25
+ owner,
26
+ created_at: new Date().toISOString(),
27
+ updated_at: new Date().toISOString(),
28
+ },
29
+ contract_payload: {
30
+ cadrage: { title, summary },
31
+ },
32
+ });
33
+
34
+ const filepath = join(contractsDir, `${id}.json`);
35
+ if (existsSync(filepath)) {
36
+ throw new Error(`Mission Contract ${id} existe deja.`);
37
+ }
38
+
39
+ writeFileSync(filepath, JSON.stringify(contract, null, 2) + '\n', 'utf-8');
40
+ return { filepath, id, status: 'DRAFT' };
41
+ }
42
+
43
+ /**
44
+ * List all Mission Contracts.
45
+ */
46
+ export function listMissions(hcmDir) {
47
+ const contractsDir = join(hcmDir, 'contracts');
48
+ if (!existsSync(contractsDir)) return [];
49
+
50
+ return readdirSync(contractsDir)
51
+ .filter(f => f.startsWith('MCT-') && f.endsWith('.json'))
52
+ .map(f => {
53
+ try {
54
+ const data = JSON.parse(readFileSync(join(contractsDir, f), 'utf-8'));
55
+ return {
56
+ id: data.contract_meta?.id || f.replace('.json', ''),
57
+ title: data.contract_payload?.cadrage?.title || '',
58
+ status: data.contract_meta?.status || 'UNKNOWN',
59
+ };
60
+ } catch (_e) { /* fallback: malformed contract JSON */
61
+ return { id: f.replace('.json', ''), title: '', status: 'ERROR' };
62
+ }
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Validate a Mission Contract — transitions DRAFT → VALIDATED.
68
+ * @param {string} id - MCT ID
69
+ * @param {string} hcmDir
70
+ * @returns {{ id: string, status: string }}
71
+ */
72
+ export function validateMission(id, hcmDir) {
73
+ const contractsDir = join(hcmDir, 'contracts');
74
+ const filepath = join(contractsDir, `${id}.json`);
75
+
76
+ if (!existsSync(filepath)) {
77
+ throw new Error(`Mission Contract ${id} introuvable.`);
78
+ }
79
+
80
+ const data = JSON.parse(readFileSync(filepath, 'utf-8'));
81
+ const status = data.contract_meta?.status;
82
+
83
+ if (status === 'VALIDATED') {
84
+ throw new Error(`Mission Contract ${id} est deja VALIDATED.`);
85
+ }
86
+
87
+ if (data.contract_meta) {
88
+ data.contract_meta.status = 'VALIDATED';
89
+ data.contract_meta.validated_at = new Date().toISOString();
90
+ data.contract_meta.updated_at = new Date().toISOString();
91
+ }
92
+
93
+ writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
94
+ return { id, status: 'VALIDATED' };
95
+ }
96
+
97
+ /**
98
+ * Inspect a specific Mission Contract — full content.
99
+ */
100
+ export function inspectMission(id, hcmDir) {
101
+ const contractsDir = join(hcmDir, 'contracts');
102
+ const filepath = join(contractsDir, `${id}.json`);
103
+
104
+ if (!existsSync(filepath)) {
105
+ throw new Error(`Mission Contract ${id} introuvable.`);
106
+ }
107
+
108
+ return JSON.parse(readFileSync(filepath, 'utf-8'));
109
+ }
@@ -0,0 +1,36 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const NOTEWRITER_DEFAULTS = {
5
+ note_enabled: true,
6
+ note_min_level: 'L1',
7
+ cr_enabled: true,
8
+ cr_timer_minutes: 30,
9
+ cr_min_level_for_inclusion: 'L3',
10
+ };
11
+
12
+ export const VALID_LEVELS = ['L1', 'L2', 'L3', 'L4', 'L5'];
13
+
14
+ export function readNotewriterConfig(projectRoot) {
15
+ const servicesPath = join(projectRoot, '.nemesis', 'services.json');
16
+ if (!existsSync(servicesPath)) return { ...NOTEWRITER_DEFAULTS };
17
+ try {
18
+ const data = JSON.parse(readFileSync(servicesPath, 'utf-8'));
19
+ const nw = data?.services?.notewriter || {};
20
+ return { ...NOTEWRITER_DEFAULTS, ...nw };
21
+ } catch (_e) { /* fallback: corrupted services config */
22
+ return { ...NOTEWRITER_DEFAULTS };
23
+ }
24
+ }
25
+
26
+ export function writeNotewriterConfig(projectRoot, config) {
27
+ const servicesPath = join(projectRoot, '.nemesis', 'services.json');
28
+ let data = {};
29
+ if (existsSync(servicesPath)) {
30
+ try { data = JSON.parse(readFileSync(servicesPath, 'utf-8')); }
31
+ catch (_e) { /* fallback: corrupted services config */ data = {}; }
32
+ }
33
+ if (!data.services) data.services = {};
34
+ data.services.notewriter = config;
35
+ writeFileSync(servicesPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
36
+ }
@@ -0,0 +1,237 @@
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveCrDir, resolvePendingCrPath, ensureDir } from './paths.js';
4
+ import { readNwRegistry, nextCrId, registerCr, getLastCr, getNotesSinceCr } from './registry.js';
5
+ import { readNote } from './notes.js';
6
+ import { error as logError } from '../logger.js';
7
+
8
+ const CR_SYSTEM_PROMPT = `Tu es un systeme d'observation silencieux.
9
+ Tu produis un compte-rendu incremental a partir de notes d'observation.
10
+
11
+ ## Contexte
12
+ Tu recois :
13
+ - Un resume du CR precedent (si existant)
14
+ - Les notes d'observation de la periode courante
15
+
16
+ ## Format de sortie
17
+
18
+ Reponds UNIQUEMENT avec un JSON valide :
19
+
20
+ {
21
+ "title": "Titre synthetique de la periode",
22
+ "summary": "Synthese narrative de la periode (3-8 phrases)",
23
+ "decisions": ["Decision 1 actee", "Decision 2 actee"],
24
+ "actions": ["Action en attente 1", "Action en attente 2"],
25
+ "recontextualization": "Bloc narratif pret a injecter a l'agent. Contient : ou on en est, ce qui a ete decide, ce qui reste a faire, etat de la mission. 5-10 phrases."
26
+ }`;
27
+
28
+ export { CR_SYSTEM_PROMPT };
29
+
30
+ export function buildCrPrompt(previousCrSummary, notes) {
31
+ let prompt = '';
32
+ if (previousCrSummary) {
33
+ prompt += `## Resume du CR precedent\n${previousCrSummary}\n\n`;
34
+ }
35
+ prompt += `## Notes de la periode (${notes.length} notes)\n\n`;
36
+ for (const note of notes) {
37
+ prompt += `### ${note.id} [${note.level}]\n`;
38
+ prompt += `${note.content}\n`;
39
+ if (note.extractedActions?.length > 0) {
40
+ prompt += `Actions : ${note.extractedActions.join(', ')}\n`;
41
+ }
42
+ prompt += '\n';
43
+ }
44
+ return prompt;
45
+ }
46
+
47
+ export function parseCrResponse(text) {
48
+ try {
49
+ let json = text.trim();
50
+ if (json.startsWith('```')) {
51
+ json = json.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '');
52
+ }
53
+ const parsed = JSON.parse(json);
54
+ return {
55
+ title: parsed.title || 'CR sans titre',
56
+ summary: parsed.summary || '',
57
+ decisions: Array.isArray(parsed.decisions) ? parsed.decisions : [],
58
+ actions: Array.isArray(parsed.actions) ? parsed.actions : [],
59
+ recontextualization: parsed.recontextualization || '',
60
+ };
61
+ } catch (_e) {
62
+ return {
63
+ title: 'CR non parsable',
64
+ summary: text.slice(0, 500),
65
+ decisions: [], actions: [],
66
+ recontextualization: text.slice(0, 300),
67
+ };
68
+ }
69
+ }
70
+
71
+ export async function generateCR(projectRoot, agentName, trigger, callLlm) {
72
+ // 1. Notes depuis le dernier CR
73
+ const noteEntries = getNotesSinceCr(projectRoot, agentName);
74
+ if (noteEntries.length === 0) return null;
75
+
76
+ // 2. Charger les notes completes
77
+ const notes = [];
78
+ for (const entry of noteEntries) {
79
+ const note = readNote(projectRoot, agentName, entry.id);
80
+ if (note) notes.push(note);
81
+ }
82
+ if (notes.length === 0) return null;
83
+
84
+ // 3. CR precedent
85
+ const lastCr = getLastCr(projectRoot, agentName);
86
+ let previousCrSummary = null;
87
+ let previousCrId = null;
88
+ if (lastCr) {
89
+ previousCrId = lastCr.id;
90
+ const crDir = resolveCrDir(projectRoot, agentName);
91
+ const crPath = join(crDir, `${lastCr.id}.json`);
92
+ if (existsSync(crPath)) {
93
+ try {
94
+ const crData = JSON.parse(readFileSync(crPath, 'utf-8'));
95
+ previousCrSummary = crData.summary || null;
96
+ } catch (_e) { /* fallback: previous CR unreadable */ }
97
+ }
98
+ }
99
+
100
+ // 4. Appeler le LLM
101
+ const userPrompt = buildCrPrompt(previousCrSummary, notes);
102
+ let llmResponse;
103
+ try {
104
+ llmResponse = await callLlm(CR_SYSTEM_PROMPT, userPrompt);
105
+ } catch (err) {
106
+ logError(`NoteWriter: erreur LLM CR — ${err.message}`);
107
+ return null;
108
+ }
109
+
110
+ // 5. Parser
111
+ const parsed = parseCrResponse(llmResponse);
112
+
113
+ // 6. Construire le CR
114
+ const crId = nextCrId(projectRoot, agentName);
115
+ const now = new Date().toISOString();
116
+ const cr = {
117
+ id: crId,
118
+ projectId: null,
119
+ agentId: agentName,
120
+ sessionId: notes[0]?.sessionId || null,
121
+ title: parsed.title,
122
+ trigger,
123
+ period: {
124
+ from: notes[0]?.timestamp || now,
125
+ to: notes[notes.length - 1]?.timestamp || now,
126
+ },
127
+ previousCrId,
128
+ previousCrSummary: previousCrSummary || null,
129
+ summary: parsed.summary,
130
+ decisions: parsed.decisions,
131
+ actions: parsed.actions,
132
+ noteIds: notes.map(n => n.id),
133
+ noteCount: notes.length,
134
+ recontextualization: parsed.recontextualization,
135
+ generatedAt: now,
136
+ };
137
+
138
+ // 7. Ecrire le fichier CR
139
+ const crDir = resolveCrDir(projectRoot, agentName);
140
+ ensureDir(crDir);
141
+ writeFileSync(join(crDir, `${crId}.json`), JSON.stringify(cr, null, 2) + '\n', 'utf-8');
142
+
143
+ // 8. Registry
144
+ registerCr(projectRoot, agentName, {
145
+ id: crId, timestamp: now, trigger,
146
+ noteCount: notes.length, previousCrId,
147
+ });
148
+
149
+ // 9. .pending-cr
150
+ writePendingCr(projectRoot, agentName, cr);
151
+
152
+ return cr;
153
+ }
154
+
155
+ // --- Timer lazy ---
156
+
157
+ export function shouldGenerateCR(projectRoot, agentName, configTimerMinutes = 30) {
158
+ const noteEntries = getNotesSinceCr(projectRoot, agentName);
159
+ if (noteEntries.length === 0) return false;
160
+
161
+ const oldestNote = noteEntries[0];
162
+ if (!oldestNote.timestamp) return false;
163
+
164
+ const elapsed = (Date.now() - new Date(oldestNote.timestamp).getTime()) / (1000 * 60);
165
+ return elapsed >= configTimerMinutes;
166
+ }
167
+
168
+ // --- Read / List ---
169
+
170
+ export function readCr(projectRoot, agentName, crId) {
171
+ const crDir = resolveCrDir(projectRoot, agentName);
172
+ const crPath = join(crDir, `${crId}.json`);
173
+ if (!existsSync(crPath)) return null;
174
+ try { return JSON.parse(readFileSync(crPath, 'utf-8')); }
175
+ catch (_e) { /* fallback: corrupted CR */ return null; }
176
+ }
177
+
178
+ export function listCrs(projectRoot, agentName) {
179
+ const reg = readNwRegistry(projectRoot, agentName);
180
+ return reg.crs;
181
+ }
182
+
183
+ // --- .pending-cr ---
184
+
185
+ export function writePendingCr(projectRoot, agentName, cr) {
186
+ const pendingPath = resolvePendingCrPath(projectRoot, agentName);
187
+ ensureDir(join(pendingPath, '..'));
188
+ const payload = {
189
+ crId: cr.id,
190
+ recontextualization: cr.recontextualization,
191
+ summary: cr.summary,
192
+ decisions: cr.decisions,
193
+ actions: cr.actions,
194
+ writtenAt: new Date().toISOString(),
195
+ };
196
+ writeFileSync(pendingPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
197
+ }
198
+
199
+ export function consumePendingCr(projectRoot, agentName) {
200
+ const pendingPath = resolvePendingCrPath(projectRoot, agentName);
201
+ if (!existsSync(pendingPath)) return null;
202
+
203
+ try {
204
+ const data = JSON.parse(readFileSync(pendingPath, 'utf-8'));
205
+
206
+ // TTL 1h
207
+ if (data.writtenAt) {
208
+ const age = Date.now() - new Date(data.writtenAt).getTime();
209
+ if (age > 60 * 60 * 1000) {
210
+ unlinkSync(pendingPath);
211
+ return null;
212
+ }
213
+ }
214
+
215
+ // Consommer
216
+ unlinkSync(pendingPath);
217
+ return formatCrForInjection(data);
218
+ } catch (_e) {
219
+ try { unlinkSync(pendingPath); } catch (_e) { /* fallback: already removed */ }
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function formatCrForInjection(data) {
225
+ let text = `Voici le compte-rendu de la derniere periode de travail :\n\n`;
226
+ text += `**${data.crId}**\n\n`;
227
+ text += data.recontextualization || data.summary || '';
228
+ if (data.decisions?.length > 0) {
229
+ text += `\n\n**Decisions actees :**\n`;
230
+ for (const d of data.decisions) text += `- ${d}\n`;
231
+ }
232
+ if (data.actions?.length > 0) {
233
+ text += `\n\n**Actions en attente :**\n`;
234
+ for (const a of data.actions) text += `- ${a}\n`;
235
+ }
236
+ return text;
237
+ }