hafez 1.0.1

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/dist/cli/batch-errors.d.ts +24 -0
  4. package/dist/cli/batch-errors.js +236 -0
  5. package/dist/cli/commands-schema.d.ts +3 -0
  6. package/dist/cli/commands-schema.js +80 -0
  7. package/dist/cli/commands.d.ts +133 -0
  8. package/dist/cli/commands.js +628 -0
  9. package/dist/cli/format.d.ts +9 -0
  10. package/dist/cli/format.js +158 -0
  11. package/dist/cli/help-agent.d.ts +6 -0
  12. package/dist/cli/help-agent.js +158 -0
  13. package/dist/cli/index.d.ts +1 -0
  14. package/dist/cli/index.js +304 -0
  15. package/dist/cli/resolve-vault.d.ts +13 -0
  16. package/dist/cli/resolve-vault.js +124 -0
  17. package/dist/cli/schema-introspect.d.ts +70 -0
  18. package/dist/cli/schema-introspect.js +372 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +3 -0
  21. package/dist/contracts.d.ts +26 -0
  22. package/dist/contracts.js +87 -0
  23. package/dist/db.d.ts +68 -0
  24. package/dist/db.js +663 -0
  25. package/dist/digest.d.ts +29 -0
  26. package/dist/digest.js +94 -0
  27. package/dist/export-okf.d.ts +12 -0
  28. package/dist/export-okf.js +176 -0
  29. package/dist/git.d.ts +12 -0
  30. package/dist/git.js +355 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +909 -0
  33. package/dist/knowledge-index.d.ts +3 -0
  34. package/dist/knowledge-index.js +135 -0
  35. package/dist/merge.d.ts +1 -0
  36. package/dist/merge.js +176 -0
  37. package/dist/migrate-knowledge-v2.d.ts +11 -0
  38. package/dist/migrate-knowledge-v2.js +158 -0
  39. package/dist/migrate-next-actions.d.ts +6 -0
  40. package/dist/migrate-next-actions.js +32 -0
  41. package/dist/migrate-types.d.ts +11 -0
  42. package/dist/migrate-types.js +142 -0
  43. package/dist/parser.d.ts +18 -0
  44. package/dist/parser.js +68 -0
  45. package/dist/query.d.ts +24 -0
  46. package/dist/query.js +167 -0
  47. package/dist/schema.d.ts +9 -0
  48. package/dist/schema.js +59 -0
  49. package/dist/sections.d.ts +54 -0
  50. package/dist/sections.js +297 -0
  51. package/dist/templates.d.ts +8 -0
  52. package/dist/templates.js +39 -0
  53. package/dist/types.d.ts +321 -0
  54. package/dist/types.js +11 -0
  55. package/dist/vault.d.ts +12 -0
  56. package/dist/vault.js +45 -0
  57. package/package.json +67 -0
  58. package/skill/SKILL.md +190 -0
package/dist/index.js ADDED
@@ -0,0 +1,909 @@
1
+ // src/index.ts
2
+ import { parseFilePath, serializeFile, slugify, resolveFilePath } from './vault.js';
3
+ import { gitCommitAndPush, gitSync, gitChangelog } from './git.js';
4
+ import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
5
+ import { bodyTemplate, knowledgeBodyTemplate } from './templates.js';
6
+ import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry } from './parser.js';
7
+ import { setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findNextStructuralHeading, findSection } from './sections.js';
8
+ import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
9
+ import { getContract } from './contracts.js';
10
+ import { createIndex } from './db.js';
11
+ import { generateVaultIndex } from './knowledge-index.js';
12
+ import { HafezError } from './types.js';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, unlinkSync } from 'fs';
14
+ import { join, relative, basename } from 'path';
15
+ export function createHafez(config) {
16
+ const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
17
+ // Per-instance mutex — each createHafez() gets its own lock
18
+ let lockPromise = Promise.resolve();
19
+ function withLock(fn) {
20
+ const prev = lockPromise;
21
+ let release;
22
+ lockPromise = new Promise(resolve => { release = resolve; });
23
+ return prev.then(fn).finally(() => release());
24
+ }
25
+ function entityPath(slug) { return resolveFilePath(vaultPath, slug, 'entity'); }
26
+ function knowledgePath(slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
27
+ function findFile(slug) {
28
+ const ep = entityPath(slug);
29
+ if (existsSync(ep))
30
+ return ep;
31
+ const kp = knowledgePath(slug);
32
+ if (existsSync(kp))
33
+ return kp;
34
+ return null;
35
+ }
36
+ function exists(slug) { return findFile(slug) !== null; }
37
+ function today() { return new Date().toISOString().slice(0, 10); }
38
+ function kindFromPath(filePath) {
39
+ return filePath.includes('/entities/') ? 'entity' : 'knowledge';
40
+ }
41
+ const ENTITY_ONLY_FIELDS = ['status', 'next_action', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
42
+ const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
43
+ function validateKindFields(kind, fields) {
44
+ if (kind === 'knowledge') {
45
+ const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined);
46
+ if (invalid.length > 0) {
47
+ throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
48
+ }
49
+ }
50
+ if (kind === 'entity') {
51
+ const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
52
+ if (invalid.length > 0) {
53
+ throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
54
+ }
55
+ }
56
+ }
57
+ function regenerateIndexSafe() {
58
+ // Runs post-commit: the vault write is already persisted/pushed, so an
59
+ // index.md failure must warn, never reject the operation.
60
+ try {
61
+ generateVaultIndex(vaultPath);
62
+ }
63
+ catch (err) {
64
+ process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
65
+ }
66
+ }
67
+ // Shared by create() and batch() so optional fields can't silently diverge
68
+ // between the two paths (see CLAUDE.md Validation Gotchas).
69
+ function buildEntityFrontmatter(name, ef) {
70
+ const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
71
+ if (ef.domain?.length)
72
+ fm.domain = ef.domain;
73
+ if (ef.parent) {
74
+ if (!exists(ef.parent))
75
+ throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
76
+ fm.parent = ef.parent;
77
+ }
78
+ if (ef.related?.length) {
79
+ for (const r of ef.related) {
80
+ if (!exists(r))
81
+ throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
82
+ }
83
+ fm.related = ef.related;
84
+ }
85
+ if (ef.tags?.length)
86
+ fm.tags = ef.tags;
87
+ if (ef.description)
88
+ fm.description = ef.description;
89
+ if (ef.resource)
90
+ fm.resource = ef.resource;
91
+ const errors = validateEntityFrontmatter(fm);
92
+ if (errors.length > 0)
93
+ throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
94
+ return fm;
95
+ }
96
+ function buildKnowledgeFrontmatter(name, kf) {
97
+ const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
98
+ if (kf.domain?.length)
99
+ fm.domain = kf.domain;
100
+ if (kf.related?.length) {
101
+ for (const r of kf.related) {
102
+ if (!exists(r))
103
+ throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
104
+ }
105
+ fm.related = kf.related;
106
+ }
107
+ if (kf.tags?.length)
108
+ fm.tags = kf.tags;
109
+ if (kf.description)
110
+ fm.description = kf.description;
111
+ const errors = validateKnowledgeFrontmatter(fm);
112
+ if (errors.length > 0)
113
+ throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
114
+ if (kf.subtype)
115
+ fm.subtype = kf.subtype;
116
+ if (kf['session-date'])
117
+ fm['session-date'] = kf['session-date'];
118
+ return fm;
119
+ }
120
+ function replaceSection(body, heading, content, insertBefore) {
121
+ const idx = body.indexOf(heading);
122
+ if (idx >= 0) {
123
+ const afterPos = idx + heading.length;
124
+ const nextH = findNextStructuralHeading(body, afterPos);
125
+ if (nextH !== -1) {
126
+ return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
127
+ }
128
+ return body.slice(0, afterPos) + '\n\n' + content + '\n';
129
+ }
130
+ if (insertBefore) {
131
+ const beforeIdx = body.indexOf(insertBefore);
132
+ if (beforeIdx >= 0) {
133
+ return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
134
+ }
135
+ }
136
+ return body + '\n\n' + heading + '\n\n' + content + '\n';
137
+ }
138
+ function insertSessionLog(body, entry) {
139
+ const slHeader = '## Session Log';
140
+ const slIdx = body.indexOf(slHeader);
141
+ if (slIdx >= 0) {
142
+ const insertPos = slIdx + slHeader.length;
143
+ return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
144
+ }
145
+ return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
146
+ }
147
+ function appendToSection(body, heading, content) {
148
+ const idx = body.indexOf(heading);
149
+ if (idx >= 0) {
150
+ const afterPos = idx + heading.length;
151
+ const nextH = findNextStructuralHeading(body, afterPos);
152
+ if (nextH !== -1) {
153
+ return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
154
+ }
155
+ return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
156
+ }
157
+ return body + '\n\n' + heading + '\n\n' + content + '\n';
158
+ }
159
+ function applyUpdateFields(fm, body, fields) {
160
+ if (fields.status !== undefined)
161
+ fm.status = fields.status;
162
+ if (fields.next_action !== undefined) {
163
+ if (fields.next_action === null)
164
+ delete fm['next-action'];
165
+ else
166
+ fm['next-action'] = fields.next_action;
167
+ }
168
+ if (fields.current_state !== undefined) {
169
+ body = replaceSection(body, '## Current State', fields.current_state, '## Session Log');
170
+ }
171
+ if (fields.brief !== undefined) {
172
+ body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
173
+ }
174
+ let matchedAction;
175
+ if (fields.add_action)
176
+ body = addNextAction(body, fields.add_action);
177
+ if (fields.add_actions) {
178
+ for (const action of fields.add_actions) {
179
+ body = addNextAction(body, action);
180
+ }
181
+ }
182
+ if (fields.complete_action) {
183
+ const r = completeNextAction(body, fields.complete_action);
184
+ body = r.body;
185
+ matchedAction = r.matched;
186
+ }
187
+ if (fields.remove_action) {
188
+ const r = removeNextAction(body, fields.remove_action);
189
+ body = r.body;
190
+ matchedAction = r.matched;
191
+ }
192
+ if (fields.clear_actions)
193
+ body = clearNextActions(body);
194
+ if (fields.session_log) {
195
+ const logErrors = validateSessionLogEntry(fields.session_log);
196
+ if (logErrors.length > 0)
197
+ throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
198
+ body = insertSessionLog(body, formatSessionLogEntry(fields.session_log));
199
+ }
200
+ // Domain (shared) + knowledge-only metadata
201
+ if (fields.domain !== undefined) {
202
+ if (fields.domain.length === 0)
203
+ delete fm.domain;
204
+ else
205
+ fm.domain = fields.domain;
206
+ }
207
+ if (fields.description !== undefined) {
208
+ if (fields.description === '' || fields.description === null)
209
+ delete fm.description;
210
+ else
211
+ fm.description = fields.description;
212
+ }
213
+ if (fields.resource !== undefined) {
214
+ if (fields.resource === '' || fields.resource === null)
215
+ delete fm.resource;
216
+ else
217
+ fm.resource = fields.resource;
218
+ }
219
+ if (fields.confidence !== undefined)
220
+ fm.confidence = fields.confidence;
221
+ if (fields.tags !== undefined)
222
+ fm.tags = fields.tags;
223
+ if (fields.related !== undefined) {
224
+ for (const r of fields.related) {
225
+ if (!exists(r))
226
+ throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
227
+ }
228
+ fm.related = fields.related;
229
+ }
230
+ if (fields.synthesis !== undefined) {
231
+ body = replaceSection(body, '## Synthesis', fields.synthesis);
232
+ }
233
+ if (fields.add_evidence) {
234
+ const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
235
+ body = appendToSection(body, '## Evidence', sanitized);
236
+ fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
237
+ fm['last-reinforced'] = today();
238
+ // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
239
+ // removed. Confidence changes are now a deliberate decision via update --confidence.
240
+ }
241
+ if (fields.add_source) {
242
+ body = appendToSection(body, '## Sources', fields.add_source);
243
+ }
244
+ if (fm['last-touched'] !== today())
245
+ fm['last-touched'] = today();
246
+ return { fm, body, matchedAction };
247
+ }
248
+ let _index = null;
249
+ let _indexInitialized = false;
250
+ function getIndex() {
251
+ if (!_indexInitialized) {
252
+ _index = readOnly ? createIndex(vaultPath, { readonly: true }) : createIndex(vaultPath);
253
+ // A null read-only open means the on-disk index is missing or from another
254
+ // schema version. Don't latch it: retry on the next call so a long-lived
255
+ // read-only instance starts serving once a write-capable process rebuilds.
256
+ _indexInitialized = !readOnly || _index !== null;
257
+ }
258
+ return _index;
259
+ }
260
+ async function read(slug, depth = 'summary') {
261
+ const filePath = findFile(slug);
262
+ if (!filePath)
263
+ throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
264
+ const parsed = parseFilePath(filePath);
265
+ const frontmatter = parsed.frontmatter;
266
+ const body = parsed.body;
267
+ if (depth === 'frontmatter')
268
+ return { frontmatter, body: '' };
269
+ if (depth === 'summary') {
270
+ const sessionLogIdx = body.indexOf('## Session Log');
271
+ return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
272
+ }
273
+ return { frontmatter, body };
274
+ }
275
+ async function create(kind, name, fields) {
276
+ if (readOnly)
277
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
278
+ return withLock(async () => {
279
+ const slug = slugify(name);
280
+ if (exists(slug))
281
+ throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
282
+ if (kind === 'entity') {
283
+ const ef = fields;
284
+ const fm = buildEntityFrontmatter(name, ef);
285
+ let body = bodyTemplate(ef.type, { purpose: ef.purpose });
286
+ if (ef.brief)
287
+ body = setBrief(body, ef.brief);
288
+ if (ef.add_action)
289
+ body = addNextAction(body, ef.add_action);
290
+ const filePath = entityPath(slug);
291
+ mkdirSync(join(vaultPath, 'entities'), { recursive: true });
292
+ body = syncRelatedSection(body, fm);
293
+ writeFileSync(filePath, serializeFile(fm, body));
294
+ await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
295
+ getIndex().upsertFromFile(slug, 'entity');
296
+ onWrite?.(slug, 'create');
297
+ }
298
+ else {
299
+ const kf = (fields || {});
300
+ const fm = buildKnowledgeFrontmatter(name, kf);
301
+ let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
302
+ const filePath = knowledgePath(slug);
303
+ mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
304
+ body = syncRelatedSection(body, fm);
305
+ writeFileSync(filePath, serializeFile(fm, body));
306
+ await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
307
+ getIndex().upsertFromFile(slug, 'knowledge');
308
+ onWrite?.(slug, 'create');
309
+ }
310
+ regenerateIndexSafe();
311
+ return slug;
312
+ });
313
+ }
314
+ async function capture(name, notes) {
315
+ if (readOnly)
316
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
317
+ return withLock(async () => {
318
+ const slug = slugify(name);
319
+ if (exists(slug))
320
+ throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
321
+ const fm = {
322
+ name,
323
+ type: 'capture',
324
+ status: 'active',
325
+ created: today(),
326
+ 'last-touched': today(),
327
+ };
328
+ const body = bodyTemplate('capture', { notes });
329
+ const filePath = entityPath(slug);
330
+ mkdirSync(join(vaultPath, 'entities'), { recursive: true });
331
+ writeFileSync(filePath, serializeFile(fm, body));
332
+ await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `capture: ${name}`, gitConfig);
333
+ getIndex().upsertFromFile(slug, 'entity');
334
+ regenerateIndexSafe();
335
+ onWrite?.(slug, 'capture');
336
+ return slug;
337
+ });
338
+ }
339
+ async function update(slug, fields) {
340
+ if (readOnly)
341
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
342
+ return withLock(async () => {
343
+ const filePath = findFile(slug);
344
+ if (!filePath)
345
+ throw new HafezError('NOT_FOUND', `'${slug}' not found`);
346
+ const kind = kindFromPath(filePath);
347
+ validateKindFields(kind, fields);
348
+ let { frontmatter: fm, body } = parseFilePath(filePath);
349
+ const previousNextAction = fm['next-action'] ?? null;
350
+ const applied = applyUpdateFields(fm, body, fields);
351
+ fm = applied.fm;
352
+ body = applied.body;
353
+ // Session log archival
354
+ if (countSessionLogEntries(body) >= 10) {
355
+ const extracted = extractOldestSessionLogEntry(body);
356
+ if (extracted) {
357
+ const archiveDir = join(vaultPath, 'entities', 'archive');
358
+ mkdirSync(archiveDir, { recursive: true });
359
+ const archivePath = join(archiveDir, `${slug}-log.md`);
360
+ appendFileSync(archivePath, extracted.entry + '\n\n');
361
+ body = extracted.remaining;
362
+ }
363
+ }
364
+ body = syncRelatedSection(body, fm);
365
+ writeFileSync(filePath, serializeFile(fm, body));
366
+ const files = [relative(vaultPath, filePath)];
367
+ const archivePath = join(vaultPath, 'entities', 'archive', `${slug}-log.md`);
368
+ if (existsSync(archivePath))
369
+ files.push(relative(vaultPath, archivePath));
370
+ await gitCommitAndPush(vaultPath, files, `update: ${slug}`, gitConfig);
371
+ getIndex().upsertFromFile(slug, kind);
372
+ regenerateIndexSafe();
373
+ onWrite?.(slug, 'update');
374
+ return { previous_next_action: previousNextAction, matched_action: applied.matchedAction };
375
+ });
376
+ }
377
+ async function link(slug, target, relation) {
378
+ if (readOnly)
379
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
380
+ return withLock(async () => {
381
+ const filePath = findFile(slug);
382
+ if (!filePath)
383
+ throw new HafezError('NOT_FOUND', `'${slug}' not found`);
384
+ if (!exists(target))
385
+ throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
386
+ let { frontmatter: fm, body } = parseFilePath(filePath);
387
+ if (relation === 'parent') {
388
+ fm.parent = target;
389
+ }
390
+ else {
391
+ const related = fm.related || [];
392
+ if (!related.includes(target))
393
+ related.push(target);
394
+ fm.related = related;
395
+ }
396
+ body = syncRelatedSection(body, fm);
397
+ writeFileSync(filePath, serializeFile(fm, body));
398
+ await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `link: ${slug} → ${target} (${relation})`, gitConfig);
399
+ const kind = kindFromPath(filePath);
400
+ getIndex().upsertFromFile(slug, kind);
401
+ regenerateIndexSafe();
402
+ onWrite?.(slug, 'link');
403
+ });
404
+ }
405
+ async function unlink(slug, target, relation) {
406
+ if (readOnly)
407
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
408
+ return withLock(async () => {
409
+ const filePath = findFile(slug);
410
+ if (!filePath)
411
+ throw new HafezError('NOT_FOUND', `'${slug}' not found`);
412
+ let { frontmatter: fm, body } = parseFilePath(filePath);
413
+ if (relation === 'parent' && fm.parent === target) {
414
+ delete fm.parent;
415
+ }
416
+ else if (relation === 'related' && fm.related) {
417
+ fm.related = fm.related.filter((r) => r !== target);
418
+ if (fm.related.length === 0)
419
+ delete fm.related;
420
+ }
421
+ body = syncRelatedSection(body, fm);
422
+ writeFileSync(filePath, serializeFile(fm, body));
423
+ await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
424
+ const kind = kindFromPath(filePath);
425
+ getIndex().upsertFromFile(slug, kind);
426
+ regenerateIndexSafe();
427
+ onWrite?.(slug, 'unlink');
428
+ });
429
+ }
430
+ async function batch(operations) {
431
+ if (readOnly)
432
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
433
+ return withLock(async () => {
434
+ // Capture originals for rollback
435
+ const originals = new Map();
436
+ function captureOriginal(filePath) {
437
+ if (!originals.has(filePath)) {
438
+ originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
439
+ }
440
+ }
441
+ const modifiedFiles = [];
442
+ const affectedSlugs = [];
443
+ const results = [];
444
+ try {
445
+ for (const op of operations) {
446
+ if (op.op === 'update') {
447
+ const filePath = findFile(op.slug);
448
+ if (!filePath)
449
+ throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
450
+ captureOriginal(filePath);
451
+ validateKindFields(kindFromPath(filePath), op.fields);
452
+ const { frontmatter: fm, body } = parseFilePath(filePath);
453
+ const applied = applyUpdateFields(fm, body, op.fields);
454
+ applied.body = syncRelatedSection(applied.body, applied.fm);
455
+ writeFileSync(filePath, serializeFile(applied.fm, applied.body));
456
+ modifiedFiles.push(relative(vaultPath, filePath));
457
+ affectedSlugs.push(op.slug);
458
+ results.push({ op: 'update', slug: op.slug, status: 'ok' });
459
+ }
460
+ else if (op.op === 'create' && op.kind === 'session') {
461
+ const slug = slugify(op.name);
462
+ if (exists(slug)) {
463
+ results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
464
+ continue;
465
+ }
466
+ const sessionDir = join(vaultPath, 'sessions');
467
+ mkdirSync(sessionDir, { recursive: true });
468
+ const filePath = join(sessionDir, `${slug}.md`);
469
+ if (existsSync(filePath)) {
470
+ results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
471
+ continue;
472
+ }
473
+ captureOriginal(filePath);
474
+ const fm = { name: op.name, created: today() };
475
+ const kf = (op.fields ?? {});
476
+ if (kf['session-date'])
477
+ fm['session-date'] = kf['session-date'];
478
+ if (kf.related?.length)
479
+ fm.related = kf.related;
480
+ // Session files use sections: Summary, Entities Touched, Decisions
481
+ const sections = ['Summary', 'Entities Touched', 'Decisions'];
482
+ const lines = [];
483
+ for (const section of sections) {
484
+ lines.push(`## ${section}`);
485
+ if (section === 'Summary' && kf.synthesis)
486
+ lines.push('', kf.synthesis);
487
+ lines.push('');
488
+ }
489
+ let sessionBody = lines.join('\n');
490
+ sessionBody = syncRelatedSection(sessionBody, fm);
491
+ writeFileSync(filePath, serializeFile(fm, sessionBody));
492
+ modifiedFiles.push(relative(vaultPath, filePath));
493
+ // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
494
+ // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
495
+ results.push({ op: 'create', slug, status: 'ok', created: true });
496
+ }
497
+ else if (op.op === 'create') {
498
+ const slug = slugify(op.name);
499
+ if (exists(slug))
500
+ throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
501
+ if (op.kind === 'entity') {
502
+ const ef = op.fields;
503
+ const fm = buildEntityFrontmatter(op.name, ef);
504
+ let entBody = bodyTemplate(ef.type, { purpose: ef.purpose });
505
+ if (ef.brief)
506
+ entBody = setBrief(entBody, ef.brief);
507
+ if (ef.add_action)
508
+ entBody = addNextAction(entBody, ef.add_action);
509
+ const filePath = entityPath(slug);
510
+ captureOriginal(filePath);
511
+ mkdirSync(join(vaultPath, 'entities'), { recursive: true });
512
+ entBody = syncRelatedSection(entBody, fm);
513
+ writeFileSync(filePath, serializeFile(fm, entBody));
514
+ modifiedFiles.push(relative(vaultPath, filePath));
515
+ affectedSlugs.push(slug);
516
+ results.push({ op: 'create', slug, status: 'ok', created: true });
517
+ }
518
+ else {
519
+ const kf = (op.fields || {});
520
+ const fm = buildKnowledgeFrontmatter(op.name, kf);
521
+ let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
522
+ const filePath = knowledgePath(slug);
523
+ captureOriginal(filePath);
524
+ mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
525
+ body = syncRelatedSection(body, fm);
526
+ writeFileSync(filePath, serializeFile(fm, body));
527
+ modifiedFiles.push(relative(vaultPath, filePath));
528
+ affectedSlugs.push(slug);
529
+ results.push({ op: 'create', slug, status: 'ok', created: true });
530
+ }
531
+ }
532
+ else if (op.op === 'capture') {
533
+ const slug = slugify(op.name);
534
+ if (exists(slug))
535
+ throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
536
+ const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
537
+ const filePath = entityPath(slug);
538
+ captureOriginal(filePath);
539
+ mkdirSync(join(vaultPath, 'entities'), { recursive: true });
540
+ writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
541
+ modifiedFiles.push(relative(vaultPath, filePath));
542
+ affectedSlugs.push(slug);
543
+ results.push({ op: 'capture', slug, status: 'ok', created: true });
544
+ }
545
+ else if (op.op === 'link') {
546
+ const filePath = findFile(op.slug);
547
+ if (!filePath)
548
+ throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
549
+ if (!exists(op.target))
550
+ throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
551
+ captureOriginal(filePath);
552
+ let { frontmatter: fm, body } = parseFilePath(filePath);
553
+ if (op.relation === 'parent') {
554
+ fm.parent = op.target;
555
+ }
556
+ else {
557
+ const related = fm.related || [];
558
+ if (!related.includes(op.target))
559
+ related.push(op.target);
560
+ fm.related = related;
561
+ }
562
+ body = syncRelatedSection(body, fm);
563
+ writeFileSync(filePath, serializeFile(fm, body));
564
+ modifiedFiles.push(relative(vaultPath, filePath));
565
+ affectedSlugs.push(op.slug);
566
+ results.push({ op: 'link', slug: op.slug, status: 'ok' });
567
+ }
568
+ else if (op.op === 'unlink') {
569
+ const filePath = findFile(op.slug);
570
+ if (!filePath)
571
+ throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
572
+ captureOriginal(filePath);
573
+ let { frontmatter: fm, body } = parseFilePath(filePath);
574
+ if (op.relation === 'parent' && fm.parent === op.target)
575
+ delete fm.parent;
576
+ else if (op.relation === 'related' && fm.related) {
577
+ fm.related = fm.related.filter((r) => r !== op.target);
578
+ if (fm.related.length === 0)
579
+ delete fm.related;
580
+ }
581
+ body = syncRelatedSection(body, fm);
582
+ writeFileSync(filePath, serializeFile(fm, body));
583
+ modifiedFiles.push(relative(vaultPath, filePath));
584
+ affectedSlugs.push(op.slug);
585
+ results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
586
+ }
587
+ else if (op.op === 'promote') {
588
+ const filePath = findFile(op.slug);
589
+ if (!filePath)
590
+ throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
591
+ captureOriginal(filePath);
592
+ if (op.target === 'knowledge') {
593
+ captureOriginal(knowledgePath(op.slug));
594
+ }
595
+ const kind = kindFromPath(filePath);
596
+ const { frontmatter: fm, body } = parseFilePath(filePath);
597
+ const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
598
+ modifiedFiles.push(...promoteFiles);
599
+ affectedSlugs.push(op.slug);
600
+ results.push({ op: 'promote', slug: op.slug, status: 'ok' });
601
+ }
602
+ }
603
+ }
604
+ catch (err) {
605
+ // Rollback all modified files
606
+ for (const [path, content] of originals) {
607
+ if (content === null) {
608
+ try {
609
+ unlinkSync(path);
610
+ }
611
+ catch { }
612
+ }
613
+ else {
614
+ writeFileSync(path, content);
615
+ }
616
+ }
617
+ throw err;
618
+ }
619
+ // Single git commit for all batch operations
620
+ const uniqueFiles = [...new Set(modifiedFiles)];
621
+ if (uniqueFiles.length > 0) {
622
+ await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
623
+ for (const slug of affectedSlugs) {
624
+ const fp = findFile(slug);
625
+ if (fp) {
626
+ getIndex().upsertFromFile(slug, kindFromPath(fp));
627
+ }
628
+ }
629
+ // Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
630
+ const anyIndexedTouched = uniqueFiles.some(f => f.startsWith('knowledge/') || f.startsWith('entities/'));
631
+ if (anyIndexedTouched) {
632
+ regenerateIndexSafe();
633
+ }
634
+ for (const slug of affectedSlugs)
635
+ onWrite?.(slug, 'batch');
636
+ }
637
+ return results;
638
+ });
639
+ }
640
+ async function validate() {
641
+ const report = {
642
+ broken_slugs: [],
643
+ orphaned_knowledge: [],
644
+ oversized_related: [],
645
+ missing_fields: [],
646
+ total_entities: 0,
647
+ total_knowledge: 0,
648
+ };
649
+ const allSlugs = new Set();
650
+ const knowledgeSlugs = new Set();
651
+ const referencedKnowledge = new Set();
652
+ // Scan entities
653
+ const entDir = join(vaultPath, 'entities');
654
+ if (existsSync(entDir)) {
655
+ const files = readdirSync(entDir).filter(f => f.endsWith('.md'));
656
+ report.total_entities = files.length;
657
+ for (const file of files) {
658
+ const slug = basename(file, '.md');
659
+ allSlugs.add(slug);
660
+ let fm;
661
+ try {
662
+ fm = parseFilePath(join(entDir, file)).frontmatter;
663
+ }
664
+ catch {
665
+ report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
666
+ continue;
667
+ }
668
+ const errors = validateEntityFrontmatter(fm);
669
+ for (const e of errors)
670
+ report.missing_fields.push({ slug, field: '', issue: e });
671
+ if (fm.parent) {
672
+ if (!exists(fm.parent))
673
+ report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
674
+ }
675
+ if (fm.related && Array.isArray(fm.related)) {
676
+ for (const r of fm.related) {
677
+ if (!exists(r))
678
+ report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
679
+ // Track referenced knowledge
680
+ referencedKnowledge.add(r);
681
+ }
682
+ }
683
+ }
684
+ }
685
+ // Scan knowledge
686
+ const knDir = join(vaultPath, 'knowledge');
687
+ if (existsSync(knDir)) {
688
+ const files = readdirSync(knDir).filter(f => f.endsWith('.md'));
689
+ report.total_knowledge = files.length;
690
+ for (const file of files) {
691
+ const slug = basename(file, '.md');
692
+ allSlugs.add(slug);
693
+ knowledgeSlugs.add(slug);
694
+ let fm;
695
+ try {
696
+ fm = parseFilePath(join(knDir, file)).frontmatter;
697
+ }
698
+ catch {
699
+ report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
700
+ continue;
701
+ }
702
+ const errors = validateKnowledgeFrontmatter(fm);
703
+ for (const e of errors)
704
+ report.missing_fields.push({ slug, field: '', issue: e });
705
+ if (fm.related && Array.isArray(fm.related)) {
706
+ for (const r of fm.related) {
707
+ if (!exists(r))
708
+ report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
709
+ }
710
+ }
711
+ }
712
+ }
713
+ // Find orphaned knowledge: no related, and not referenced by any entity
714
+ for (const slug of knowledgeSlugs) {
715
+ let fm;
716
+ try {
717
+ fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
718
+ }
719
+ catch {
720
+ continue; // already reported above
721
+ }
722
+ const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
723
+ if (!hasRelated && !referencedKnowledge.has(slug)) {
724
+ report.orphaned_knowledge.push(slug);
725
+ }
726
+ }
727
+ return report;
728
+ }
729
+ function promoteCore(slug, target, filePath, fm, body, kind) {
730
+ const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
731
+ const contract = getContract(currentType);
732
+ if (!contract.canPromoteTo.includes(target)) {
733
+ throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
734
+ }
735
+ const modifiedFiles = [];
736
+ if (target === 'knowledge') {
737
+ // Cross-kind: move file from entities/ to knowledge/
738
+ const newPath = knowledgePath(slug);
739
+ const notesSection = findSection(body, 'Notes');
740
+ const notesContent = notesSection?.content ?? '';
741
+ const newFm = {
742
+ name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
743
+ };
744
+ if (fm.description)
745
+ newFm.description = fm.description;
746
+ // resource is intentionally dropped on entity→knowledge promote (entity-only field)
747
+ if (fm.domain)
748
+ newFm.domain = fm.domain;
749
+ if (fm.related)
750
+ newFm.related = fm.related;
751
+ if (fm.tags)
752
+ newFm.tags = fm.tags;
753
+ const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
754
+ const finalBody = syncRelatedSection(knBody, newFm);
755
+ writeFileSync(newPath, serializeFile(newFm, finalBody));
756
+ unlinkSync(filePath);
757
+ modifiedFiles.push(relative(vaultPath, newPath));
758
+ // The deletion must be staged too — `git add` of a removed path stages
759
+ // the removal; omitting it leaves the vault permanently dirty.
760
+ modifiedFiles.push(relative(vaultPath, filePath));
761
+ getIndex().removeItem(slug);
762
+ }
763
+ else {
764
+ // Same-kind: capture→entity, capture→project, entity→project
765
+ fm.type = target;
766
+ fm['last-touched'] = today();
767
+ let newBody = body;
768
+ if (target === 'entity') {
769
+ newBody = newBody.replace(/^## Notes$/m, '## Context');
770
+ }
771
+ else {
772
+ // project
773
+ newBody = newBody.replace(/^## Notes$/m, '## Purpose');
774
+ newBody = newBody.replace(/^## Context$/m, '## Purpose');
775
+ if (!newBody.includes('## Goals')) {
776
+ const sections = newBody.split(/\n(?=## )/m);
777
+ const purposeIdx = sections.findIndex(s => s.startsWith('## Purpose'));
778
+ if (purposeIdx !== -1) {
779
+ sections.splice(purposeIdx + 1, 0, '## Goals\n');
780
+ }
781
+ else {
782
+ sections.push('## Goals\n');
783
+ }
784
+ newBody = sections.join('\n');
785
+ }
786
+ }
787
+ if (!newBody.includes('## Session Log'))
788
+ newBody += '\n## Session Log\n';
789
+ // Add promotion session log entry
790
+ const logEntry = `### ${today()} — system [decision]\nSummary: Promoted from ${currentType} to ${target}\n`;
791
+ const logIdx = newBody.lastIndexOf('## Session Log');
792
+ if (logIdx !== -1) {
793
+ const insertAt = logIdx + '## Session Log'.length + 1;
794
+ newBody = newBody.slice(0, insertAt) + '\n' + logEntry + newBody.slice(insertAt);
795
+ }
796
+ newBody = syncRelatedSection(newBody, fm);
797
+ writeFileSync(filePath, serializeFile(fm, newBody));
798
+ modifiedFiles.push(relative(vaultPath, filePath));
799
+ }
800
+ return { modifiedFiles };
801
+ }
802
+ async function promote(slug, target) {
803
+ if (readOnly)
804
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
805
+ return withLock(async () => {
806
+ const filePath = findFile(slug);
807
+ if (!filePath)
808
+ throw new HafezError('NOT_FOUND', `'${slug}' not found`);
809
+ const kind = kindFromPath(filePath);
810
+ // Capture original content for rollback before parsing (avoids double read)
811
+ const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
812
+ const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
813
+ const { frontmatter: fm, body } = parseFilePath(filePath);
814
+ const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
815
+ try {
816
+ const { modifiedFiles } = promoteCore(slug, target, filePath, fm, body, kind);
817
+ await gitCommitAndPush(vaultPath, modifiedFiles, `promote: ${slug} ${sourceType} → ${target}`, gitConfig);
818
+ getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
819
+ regenerateIndexSafe();
820
+ onWrite?.(slug, 'promote');
821
+ }
822
+ catch (err) {
823
+ if (target === 'knowledge' && originalContent) {
824
+ writeFileSync(filePath, originalContent);
825
+ if (knowledgeTarget && existsSync(knowledgeTarget))
826
+ unlinkSync(knowledgeTarget);
827
+ }
828
+ throw err;
829
+ }
830
+ return slug;
831
+ });
832
+ }
833
+ const emptyStats = {
834
+ counts: { active: 0, paused: 0, done: 0 },
835
+ by_type: { capture: 0, entity: 0, project: 0 },
836
+ knowledge_count: 0,
837
+ stale: [], no_next_action: [], recently_touched: [], recently_created: [],
838
+ };
839
+ return {
840
+ read,
841
+ update,
842
+ promote,
843
+ query: async (opts) => {
844
+ const idx = getIndex();
845
+ if (!idx)
846
+ return { items: [], total: 0 };
847
+ return queryEntities(idx, opts);
848
+ },
849
+ queryUnified: async (opts) => {
850
+ const idx = getIndex();
851
+ if (!idx)
852
+ return { items: [], total: 0 };
853
+ return queryUnified(idx, opts);
854
+ },
855
+ create,
856
+ capture,
857
+ link,
858
+ unlink,
859
+ batch,
860
+ sync: async () => {
861
+ if (readOnly)
862
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
863
+ return withLock(() => gitSync(vaultPath));
864
+ },
865
+ children: async (slug) => {
866
+ const idx = getIndex();
867
+ if (!idx)
868
+ return { items: [], total: 0 };
869
+ return queryChildren(idx, slug);
870
+ },
871
+ related_to: async (slug, relation) => {
872
+ const idx = getIndex();
873
+ if (!idx)
874
+ return { items: [], total: 0 };
875
+ return queryRelatedTo(idx, slug, relation);
876
+ },
877
+ query_knowledge: async (opts) => {
878
+ const idx = getIndex();
879
+ if (!idx)
880
+ return { items: [], total: 0 };
881
+ return queryKnowledge(idx, opts);
882
+ },
883
+ validate,
884
+ search: async (query, kind) => {
885
+ const idx = getIndex();
886
+ if (!idx)
887
+ return [];
888
+ idx.syncIfStale();
889
+ return idx.search(query, kind);
890
+ },
891
+ rebuildIndex: async () => {
892
+ if (readOnly)
893
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
894
+ getIndex().rebuild();
895
+ },
896
+ stats: async () => {
897
+ const idx = getIndex();
898
+ if (!idx)
899
+ return emptyStats;
900
+ idx.syncIfStale();
901
+ return idx.getStats();
902
+ },
903
+ changelog: (since) => gitChangelog(vaultPath, since),
904
+ };
905
+ }
906
+ export { HafezError } from './types.js';
907
+ export { getBrief, getNextActions, findSection } from './sections.js';
908
+ export { parseSessionLogHeading, parseSessionLog } from './parser.js';
909
+ export { formatStats, formatChangelog, formatValidation } from './cli/format.js';