hafez 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,20 +1,23 @@
1
1
  // src/index.ts
2
- import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath } from './vault.js';
3
- import { gitCommitAndPush, gitSync, gitChangelog, gitUnstage, gitFileTimes } from './git.js';
2
+ import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, scanVaultDir, archiveLogPath, isIndexedRelPath } from './vault.js';
3
+ import { createGitJournal } from './git.js';
4
4
  import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
5
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';
6
+ import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry, setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findSection, setSection, appendToSection, prependSessionLogEntry, bodyBeforeSessionLog } from './document.js';
8
7
  import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
9
- import { getContract } from './contracts.js';
8
+ import { getContract, ENTITY_STATUSES, CONFIDENCE_LEVELS } from './contracts.js';
10
9
  import { createIndex } from './db.js';
11
10
  import { generateVaultIndex } from './knowledge-index.js';
12
11
  import { HafezError } from './types.js';
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, unlinkSync } from 'fs';
14
- import { join, relative, basename } from 'path';
12
+ import { BatchOperationSchema, specFor } from './batch-ops.js';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
14
+ import { join, relative, basename, dirname } from 'path';
15
15
  import { lock as lockVault } from 'proper-lockfile';
16
16
  export function createHafez(config) {
17
17
  const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
18
+ // The persistence seam (CONTEXT.md: Journal). Everything below talks to
19
+ // history through this port; git is just the default adapter.
20
+ const journal = config.persistence ?? createGitJournal(vaultPath, gitConfig);
18
21
  // Two-level write lock: an in-process promise chain serializes calls within
19
22
  // this instance, then a cross-process advisory lock (proper-lockfile, mkdir
20
23
  // based — works on network/drvfs mounts) serializes against other hafez
@@ -164,50 +167,17 @@ export function createHafez(config) {
164
167
  fm['session-date'] = kf['session-date'];
165
168
  return fm;
166
169
  }
167
- function replaceSection(body, heading, content, insertBefore) {
168
- const idx = body.indexOf(heading);
169
- if (idx >= 0) {
170
- const afterPos = idx + heading.length;
171
- const nextH = findNextStructuralHeading(body, afterPos);
172
- if (nextH !== -1) {
173
- return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
174
- }
175
- return body.slice(0, afterPos) + '\n\n' + content + '\n';
176
- }
177
- if (insertBefore) {
178
- const beforeIdx = body.indexOf(insertBefore);
179
- if (beforeIdx >= 0) {
180
- return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
181
- }
182
- }
183
- return body + '\n\n' + heading + '\n\n' + content + '\n';
184
- }
185
- function insertSessionLog(body, entry) {
186
- const slHeader = '## Session Log';
187
- const slIdx = body.indexOf(slHeader);
188
- if (slIdx >= 0) {
189
- const insertPos = slIdx + slHeader.length;
190
- return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
191
- }
192
- return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
193
- }
194
- function appendToSection(body, heading, content) {
195
- const idx = body.indexOf(heading);
196
- if (idx >= 0) {
197
- const afterPos = idx + heading.length;
198
- const nextH = findNextStructuralHeading(body, afterPos);
199
- if (nextH !== -1) {
200
- return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
201
- }
202
- return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
203
- }
204
- return body + '\n\n' + heading + '\n\n' + content + '\n';
205
- }
206
170
  function applyUpdateFields(fm, body, fields) {
207
- if (fields.status !== undefined)
171
+ // Enum checks mirror the batch Zod shapes — without them the direct CLI
172
+ // path writes invalid values straight into frontmatter.
173
+ if (fields.status !== undefined) {
174
+ if (!ENTITY_STATUSES.includes(fields.status)) {
175
+ throw new HafezError('VALIDATION_FAILED', `Invalid status '${fields.status}'. Valid: ${ENTITY_STATUSES.join(', ')}`);
176
+ }
208
177
  fm.status = fields.status;
178
+ }
209
179
  if (fields.current_state !== undefined) {
210
- body = replaceSection(body, '## Current State', fields.current_state, '## Session Log');
180
+ body = setSection(body, 'Current State', fields.current_state, 'Session Log');
211
181
  }
212
182
  if (fields.brief !== undefined) {
213
183
  body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
@@ -236,7 +206,7 @@ export function createHafez(config) {
236
206
  const logErrors = validateSessionLogEntry(fields.session_log);
237
207
  if (logErrors.length > 0)
238
208
  throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
239
- body = insertSessionLog(body, formatSessionLogEntry(fields.session_log));
209
+ body = prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
240
210
  }
241
211
  // Domain (shared) + knowledge-only metadata
242
212
  if (fields.domain !== undefined) {
@@ -257,8 +227,12 @@ export function createHafez(config) {
257
227
  else
258
228
  fm.resource = fields.resource;
259
229
  }
260
- if (fields.confidence !== undefined)
230
+ if (fields.confidence !== undefined) {
231
+ if (!CONFIDENCE_LEVELS.includes(fields.confidence)) {
232
+ throw new HafezError('VALIDATION_FAILED', `Invalid confidence '${fields.confidence}'. Valid: ${CONFIDENCE_LEVELS.join(', ')}`);
233
+ }
261
234
  fm.confidence = fields.confidence;
235
+ }
262
236
  if (fields.tags !== undefined)
263
237
  fm.tags = fields.tags;
264
238
  if (fields.related !== undefined) {
@@ -269,18 +243,18 @@ export function createHafez(config) {
269
243
  fm.related = fields.related;
270
244
  }
271
245
  if (fields.synthesis !== undefined) {
272
- body = replaceSection(body, '## Synthesis', fields.synthesis);
246
+ body = setSection(body, 'Synthesis', fields.synthesis);
273
247
  }
274
248
  if (fields.add_evidence) {
275
249
  const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
276
- body = appendToSection(body, '## Evidence', sanitized);
250
+ body = appendToSection(body, 'Evidence', sanitized);
277
251
  fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
278
252
  fm['last-reinforced'] = today();
279
253
  // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
280
254
  // removed. Confidence changes are now a deliberate decision via update --confidence.
281
255
  }
282
256
  if (fields.add_source) {
283
- body = appendToSection(body, '## Sources', fields.add_source);
257
+ body = appendToSection(body, 'Sources', fields.add_source);
284
258
  }
285
259
  if (fm['last-touched'] !== today())
286
260
  fm['last-touched'] = today();
@@ -303,137 +277,323 @@ export function createHafez(config) {
303
277
  if (!filePath)
304
278
  throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
305
279
  const parsed = parseFilePath(filePath);
280
+ // findFile only resolves the indexed kinds, so the session arm is unreachable
281
+ const kind = parsed.kind === 'session' ? 'knowledge' : parsed.kind;
306
282
  const frontmatter = parsed.frontmatter;
307
283
  const body = parsed.body;
308
284
  if (depth === 'frontmatter')
309
- return { frontmatter, body: '' };
285
+ return { kind, frontmatter, body: '' };
310
286
  if (depth === 'summary') {
311
- const sessionLogIdx = body.indexOf('## Session Log');
312
- return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
287
+ return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
313
288
  }
314
- return { frontmatter, body };
289
+ return { kind, frontmatter, body };
315
290
  }
316
- async function create(kind, name, fields) {
317
- if (readOnly)
318
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
319
- return withLock(async () => {
320
- const slug = slugify(name);
291
+ function rollbackFiles(originals) {
292
+ for (const [path, content] of originals) {
293
+ if (content === null) {
294
+ try {
295
+ unlinkSync(path);
296
+ }
297
+ catch { }
298
+ }
299
+ else {
300
+ writeFileSync(path, content);
301
+ }
302
+ }
303
+ }
304
+ // All SQLite writes happen here, strictly after the git commit succeeded.
305
+ // A failed commit rolls back files only — the index was never touched, so
306
+ // files, git, and index stay consistent (pre-collapse, promote removed its
307
+ // index row mid-apply and a rollback lost it).
308
+ function updateIndexPostCommit(applied) {
309
+ for (const a of applied) {
310
+ for (const slug of a.indexRemovals)
311
+ getIndex().removeItem(slug);
312
+ for (const u of a.indexUpserts)
313
+ getIndex().upsertFromFile(u.slug, u.kind);
314
+ }
315
+ }
316
+ // The single apply path for every mutation. Writes vault files but never
317
+ // touches git or SQLite: callers commit [...filesWritten, ...filesDeleted]
318
+ // under commitMessage (single ops) or one composed batch message, then run
319
+ // updateIndexPostCommit. captureOriginal lets batch() snapshot files for
320
+ // rollback before they change.
321
+ function applyOperation(op, captureOriginal = () => { }) {
322
+ if (op.op === 'update') {
323
+ const filePath = findFile(op.slug);
324
+ if (!filePath)
325
+ throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
326
+ captureOriginal(filePath);
327
+ const kind = kindFromPath(filePath);
328
+ validateKindFields(kind, op.fields);
329
+ const parsedFile = parseFilePath(filePath);
330
+ let fm = parsedFile.frontmatter;
331
+ let body = parsedFile.body;
332
+ const applied = applyUpdateFields(fm, body, op.fields);
333
+ fm = applied.fm;
334
+ body = applied.body;
335
+ // Session log archival
336
+ const archivePath = archiveLogPath(vaultPath, op.slug);
337
+ if (countSessionLogEntries(body) >= 10) {
338
+ const extracted = extractOldestSessionLogEntry(body);
339
+ if (extracted) {
340
+ captureOriginal(archivePath);
341
+ mkdirSync(dirname(archivePath), { recursive: true });
342
+ appendFileSync(archivePath, extracted.entry + '\n\n');
343
+ body = extracted.remaining;
344
+ }
345
+ }
346
+ body = syncRelatedSection(body, fm);
347
+ writeFileSync(filePath, serializeFile(fm, body));
348
+ const files = [relative(vaultPath, filePath)];
349
+ if (existsSync(archivePath))
350
+ files.push(relative(vaultPath, archivePath));
351
+ return {
352
+ filesWritten: files,
353
+ filesDeleted: [],
354
+ result: { op: 'update', slug: op.slug, status: 'ok' },
355
+ commitMessage: `update: ${op.slug}`,
356
+ indexRemovals: [],
357
+ indexUpserts: [{ slug: op.slug, kind }],
358
+ matchedAction: applied.matchedAction,
359
+ };
360
+ }
361
+ if (op.op === 'create' && op.kind === 'session') {
362
+ const slug = slugify(op.name);
363
+ if (exists(slug)) {
364
+ return {
365
+ filesWritten: [], filesDeleted: [],
366
+ result: { op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` },
367
+ commitMessage: `create: ${op.name}`, indexRemovals: [], indexUpserts: [],
368
+ };
369
+ }
370
+ const sessionDir = join(vaultPath, kindDir('session'));
371
+ mkdirSync(sessionDir, { recursive: true });
372
+ const filePath = join(sessionDir, `${slug}.md`);
373
+ if (existsSync(filePath)) {
374
+ return {
375
+ filesWritten: [], filesDeleted: [],
376
+ result: { op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` },
377
+ commitMessage: `create: ${op.name}`, indexRemovals: [], indexUpserts: [],
378
+ };
379
+ }
380
+ captureOriginal(filePath);
381
+ const fm = { name: op.name, created: today() };
382
+ const kf = (op.fields ?? {});
383
+ if (kf['session-date'])
384
+ fm['session-date'] = kf['session-date'];
385
+ if (kf.related?.length)
386
+ fm.related = kf.related;
387
+ // Session files use sections: Summary, Entities Touched, Decisions
388
+ const sections = ['Summary', 'Entities Touched', 'Decisions'];
389
+ const lines = [];
390
+ for (const section of sections) {
391
+ lines.push(`## ${section}`);
392
+ if (section === 'Summary' && kf.synthesis)
393
+ lines.push('', kf.synthesis);
394
+ lines.push('');
395
+ }
396
+ let sessionBody = lines.join('\n');
397
+ sessionBody = syncRelatedSection(sessionBody, fm);
398
+ writeFileSync(filePath, serializeFile(fm, sessionBody));
399
+ // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
400
+ // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
401
+ return {
402
+ filesWritten: [relative(vaultPath, filePath)],
403
+ filesDeleted: [],
404
+ result: { op: 'create', slug, status: 'ok', created: true },
405
+ commitMessage: `create: ${op.name}`,
406
+ indexRemovals: [],
407
+ indexUpserts: [],
408
+ };
409
+ }
410
+ if (op.op === 'create') {
411
+ const slug = slugify(op.name);
321
412
  if (exists(slug))
322
413
  throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
323
- if (kind === 'entity') {
324
- const ef = fields;
325
- const fm = buildEntityFrontmatter(name, ef);
414
+ if (op.kind === 'entity') {
415
+ const ef = op.fields;
416
+ const fm = buildEntityFrontmatter(op.name, ef);
326
417
  let body = buildEntityBody(ef);
327
418
  const filePath = entityPath(slug);
328
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
419
+ captureOriginal(filePath);
420
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
329
421
  body = syncRelatedSection(body, fm);
330
422
  writeFileSync(filePath, serializeFile(fm, body));
331
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
332
- getIndex().upsertFromFile(slug, 'entity');
333
- onWrite?.(slug, 'create');
423
+ return {
424
+ filesWritten: [relative(vaultPath, filePath)],
425
+ filesDeleted: [],
426
+ result: { op: 'create', slug, status: 'ok', created: true },
427
+ commitMessage: `create: ${op.name}`,
428
+ indexRemovals: [],
429
+ indexUpserts: [{ slug, kind: 'entity' }],
430
+ };
334
431
  }
335
- else {
336
- const kf = (fields || {});
337
- const fm = buildKnowledgeFrontmatter(name, kf);
338
- let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
339
- const filePath = knowledgePath(slug);
340
- mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
341
- body = syncRelatedSection(body, fm);
342
- writeFileSync(filePath, serializeFile(fm, body));
343
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
344
- getIndex().upsertFromFile(slug, 'knowledge');
345
- onWrite?.(slug, 'create');
346
- }
347
- regenerateIndexSafe();
348
- return slug;
349
- });
350
- }
351
- async function capture(name, notes) {
352
- if (readOnly)
353
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
354
- return withLock(async () => {
355
- const slug = slugify(name);
432
+ const kf = (op.fields || {});
433
+ const fm = buildKnowledgeFrontmatter(op.name, kf);
434
+ let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
435
+ const filePath = knowledgePath(slug);
436
+ captureOriginal(filePath);
437
+ mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
438
+ body = syncRelatedSection(body, fm);
439
+ writeFileSync(filePath, serializeFile(fm, body));
440
+ return {
441
+ filesWritten: [relative(vaultPath, filePath)],
442
+ filesDeleted: [],
443
+ result: { op: 'create', slug, status: 'ok', created: true },
444
+ commitMessage: `create: ${op.name}`,
445
+ indexRemovals: [],
446
+ indexUpserts: [{ slug, kind: 'knowledge' }],
447
+ };
448
+ }
449
+ if (op.op === 'capture') {
450
+ const slug = slugify(op.name);
356
451
  if (exists(slug))
357
452
  throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
358
453
  const fm = {
359
- name,
454
+ name: op.name,
360
455
  type: 'capture',
361
456
  status: 'active',
362
457
  created: today(),
363
458
  'last-touched': today(),
364
459
  };
365
- const body = bodyTemplate('capture', { notes });
366
460
  const filePath = entityPath(slug);
367
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
461
+ captureOriginal(filePath);
462
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
463
+ writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
464
+ return {
465
+ filesWritten: [relative(vaultPath, filePath)],
466
+ filesDeleted: [],
467
+ result: { op: 'capture', slug, status: 'ok', created: true },
468
+ commitMessage: `capture: ${op.name}`,
469
+ indexRemovals: [],
470
+ indexUpserts: [{ slug, kind: 'entity' }],
471
+ };
472
+ }
473
+ if (op.op === 'link') {
474
+ const filePath = findFile(op.slug);
475
+ if (!filePath)
476
+ throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
477
+ if (!exists(op.target))
478
+ throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
479
+ captureOriginal(filePath);
480
+ const parsedFile = parseFilePath(filePath);
481
+ const fm = parsedFile.frontmatter;
482
+ let body = parsedFile.body;
483
+ if (op.relation === 'parent') {
484
+ fm.parent = op.target;
485
+ }
486
+ else {
487
+ const related = fm.related || [];
488
+ if (!related.includes(op.target))
489
+ related.push(op.target);
490
+ fm.related = related;
491
+ }
492
+ body = syncRelatedSection(body, fm);
368
493
  writeFileSync(filePath, serializeFile(fm, body));
369
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `capture: ${name}`, gitConfig);
370
- getIndex().upsertFromFile(slug, 'entity');
494
+ return {
495
+ filesWritten: [relative(vaultPath, filePath)],
496
+ filesDeleted: [],
497
+ result: { op: 'link', slug: op.slug, status: 'ok' },
498
+ commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
499
+ indexRemovals: [],
500
+ indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
501
+ };
502
+ }
503
+ if (op.op === 'unlink') {
504
+ const filePath = findFile(op.slug);
505
+ if (!filePath)
506
+ throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
507
+ captureOriginal(filePath);
508
+ const parsedFile = parseFilePath(filePath);
509
+ const fm = parsedFile.frontmatter;
510
+ let body = parsedFile.body;
511
+ if (op.relation === 'parent' && fm.parent === op.target) {
512
+ delete fm.parent;
513
+ }
514
+ else if (op.relation === 'related' && fm.related) {
515
+ fm.related = fm.related.filter((r) => r !== op.target);
516
+ if (fm.related.length === 0)
517
+ delete fm.related;
518
+ }
519
+ body = syncRelatedSection(body, fm);
520
+ writeFileSync(filePath, serializeFile(fm, body));
521
+ return {
522
+ filesWritten: [relative(vaultPath, filePath)],
523
+ filesDeleted: [],
524
+ result: { op: 'unlink', slug: op.slug, status: 'ok' },
525
+ commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
526
+ indexRemovals: [],
527
+ indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
528
+ };
529
+ }
530
+ // promote
531
+ const filePath = findFile(op.slug);
532
+ if (!filePath)
533
+ throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
534
+ captureOriginal(filePath);
535
+ if (op.target === 'knowledge')
536
+ captureOriginal(knowledgePath(op.slug));
537
+ const kind = kindFromPath(filePath);
538
+ const parsedFile = parseFilePath(filePath);
539
+ const fm = parsedFile.frontmatter;
540
+ const body = parsedFile.body;
541
+ const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
542
+ const { modifiedFiles, deletedFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
543
+ return {
544
+ filesWritten: modifiedFiles,
545
+ filesDeleted: deletedFiles,
546
+ result: { op: 'promote', slug: op.slug, status: 'ok' },
547
+ commitMessage: `promote: ${op.slug} ${sourceType} → ${op.target}`,
548
+ indexRemovals: op.target === 'knowledge' ? [op.slug] : [],
549
+ indexUpserts: [{ slug: op.slug, kind: op.target === 'knowledge' ? 'knowledge' : 'entity' }],
550
+ };
551
+ }
552
+ async function create(kind, name, fields) {
553
+ if (readOnly)
554
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
555
+ return withLock(async () => {
556
+ const a = kind === 'entity'
557
+ ? applyOperation({ op: 'create', kind, name, fields: fields })
558
+ : applyOperation({ op: 'create', kind, name, fields: fields });
559
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
560
+ updateIndexPostCommit([a]);
561
+ onWrite?.(a.result.slug, 'create');
371
562
  regenerateIndexSafe();
372
- onWrite?.(slug, 'capture');
373
- return slug;
563
+ return a.result.slug;
564
+ });
565
+ }
566
+ async function capture(name, notes) {
567
+ if (readOnly)
568
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
569
+ return withLock(async () => {
570
+ const a = applyOperation({ op: 'capture', name, notes });
571
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
572
+ updateIndexPostCommit([a]);
573
+ regenerateIndexSafe();
574
+ onWrite?.(a.result.slug, 'capture');
575
+ return a.result.slug;
374
576
  });
375
577
  }
376
578
  async function update(slug, fields) {
377
579
  if (readOnly)
378
580
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
379
581
  return withLock(async () => {
380
- const filePath = findFile(slug);
381
- if (!filePath)
382
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
383
- const kind = kindFromPath(filePath);
384
- validateKindFields(kind, fields);
385
- let { frontmatter: fm, body } = parseFilePath(filePath);
386
- const applied = applyUpdateFields(fm, body, fields);
387
- fm = applied.fm;
388
- body = applied.body;
389
- // Session log archival
390
- if (countSessionLogEntries(body) >= 10) {
391
- const extracted = extractOldestSessionLogEntry(body);
392
- if (extracted) {
393
- const archiveDir = join(vaultPath, 'entities', 'archive');
394
- mkdirSync(archiveDir, { recursive: true });
395
- const archivePath = join(archiveDir, `${slug}-log.md`);
396
- appendFileSync(archivePath, extracted.entry + '\n\n');
397
- body = extracted.remaining;
398
- }
399
- }
400
- body = syncRelatedSection(body, fm);
401
- writeFileSync(filePath, serializeFile(fm, body));
402
- const files = [relative(vaultPath, filePath)];
403
- const archivePath = join(vaultPath, 'entities', 'archive', `${slug}-log.md`);
404
- if (existsSync(archivePath))
405
- files.push(relative(vaultPath, archivePath));
406
- await gitCommitAndPush(vaultPath, files, `update: ${slug}`, gitConfig);
407
- getIndex().upsertFromFile(slug, kind);
582
+ const a = applyOperation({ op: 'update', slug, fields });
583
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
584
+ updateIndexPostCommit([a]);
408
585
  regenerateIndexSafe();
409
586
  onWrite?.(slug, 'update');
410
- return { matched_action: applied.matchedAction };
587
+ return { matched_action: a.matchedAction };
411
588
  });
412
589
  }
413
590
  async function link(slug, target, relation) {
414
591
  if (readOnly)
415
592
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
416
593
  return withLock(async () => {
417
- const filePath = findFile(slug);
418
- if (!filePath)
419
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
420
- if (!exists(target))
421
- throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
422
- let { frontmatter: fm, body } = parseFilePath(filePath);
423
- if (relation === 'parent') {
424
- fm.parent = target;
425
- }
426
- else {
427
- const related = fm.related || [];
428
- if (!related.includes(target))
429
- related.push(target);
430
- fm.related = related;
431
- }
432
- body = syncRelatedSection(body, fm);
433
- writeFileSync(filePath, serializeFile(fm, body));
434
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `link: ${slug} → ${target} (${relation})`, gitConfig);
435
- const kind = kindFromPath(filePath);
436
- getIndex().upsertFromFile(slug, kind);
594
+ const a = applyOperation({ op: 'link', slug, target, relation });
595
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
596
+ updateIndexPostCommit([a]);
437
597
  regenerateIndexSafe();
438
598
  onWrite?.(slug, 'link');
439
599
  });
@@ -442,23 +602,9 @@ export function createHafez(config) {
442
602
  if (readOnly)
443
603
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
444
604
  return withLock(async () => {
445
- const filePath = findFile(slug);
446
- if (!filePath)
447
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
448
- let { frontmatter: fm, body } = parseFilePath(filePath);
449
- if (relation === 'parent' && fm.parent === target) {
450
- delete fm.parent;
451
- }
452
- else if (relation === 'related' && fm.related) {
453
- fm.related = fm.related.filter((r) => r !== target);
454
- if (fm.related.length === 0)
455
- delete fm.related;
456
- }
457
- body = syncRelatedSection(body, fm);
458
- writeFileSync(filePath, serializeFile(fm, body));
459
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
460
- const kind = kindFromPath(filePath);
461
- getIndex().upsertFromFile(slug, kind);
605
+ const a = applyOperation({ op: 'unlink', slug, target, relation });
606
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
607
+ updateIndexPostCommit([a]);
462
608
  regenerateIndexSafe();
463
609
  onWrite?.(slug, 'unlink');
464
610
  });
@@ -478,8 +624,8 @@ export function createHafez(config) {
478
624
  const hasTies = new Set(candidates.map(day)).size < candidates.length;
479
625
  if (!hasTies)
480
626
  return rows.slice(0, 5);
481
- const times = await gitFileTimes(vaultPath, mode);
482
- const gitTime = (r) => times.get(`entities/${r.slug}.md`) ?? '';
627
+ const times = await journal.fileTimes(mode);
628
+ const gitTime = (r) => times.get(`${kindDir('entity')}/${r.slug}.md`) ?? '';
483
629
  candidates.sort((a, b) => {
484
630
  const dayCmp = day(b).localeCompare(day(a));
485
631
  if (dayCmp !== 0)
@@ -499,139 +645,60 @@ export function createHafez(config) {
499
645
  // slug → what it would be after earlier ops in this batch
500
646
  const simulated = new Map();
501
647
  const simulatedSessions = new Set();
502
- const resolve = (slug) => {
503
- const sim = simulated.get(slug);
504
- if (sim)
505
- return sim;
506
- const fp = findFile(slug);
507
- if (!fp)
508
- return null;
509
- const kind = kindFromPath(fp);
510
- if (kind === 'knowledge')
511
- return { kind, type: 'knowledge' };
512
- try {
513
- return { kind, type: parseFilePath(fp).frontmatter.type };
514
- }
515
- catch {
516
- return { kind, type: 'entity' };
517
- }
518
- };
519
648
  const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
520
- const report = [];
521
- for (const [index, op] of operations.entries()) {
522
- const errors = [];
523
- let warning;
524
- let slug;
525
- let created = false;
526
- const pushErr = (err) => {
527
- const e = err;
528
- errors.push(e.details?.length ? `${e.message}: ${e.details.join('; ')}` : e.message);
529
- };
530
- if (op.op === 'update') {
531
- slug = op.slug;
532
- const target = resolve(op.slug);
533
- if (!target)
534
- errors.push(`'${op.slug}' not found`);
535
- else {
536
- try {
537
- validateKindFields(target.kind, op.fields);
538
- }
539
- catch (err) {
540
- pushErr(err);
541
- }
542
- }
543
- if (op.fields.session_log) {
544
- const logErrors = validateSessionLogEntry(op.fields.session_log);
545
- if (logErrors.length > 0)
546
- errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
547
- }
548
- if (op.fields.related) {
549
- for (const r of op.fields.related) {
550
- if (!existsAnywhere(r))
551
- errors.push(`Related slug '${r}' does not exist`);
552
- }
553
- }
554
- }
555
- else if (op.op === 'create' && op.kind === 'session') {
556
- slug = slugify(op.name);
557
- if (exists(slug) || existsSync(join(vaultPath, 'sessions', `${slug}.md`)) || simulatedSessions.has(slug)) {
558
- warning = `session '${slug}' already exists — op will be skipped (batch continues)`;
559
- }
560
- else {
561
- simulatedSessions.add(slug);
562
- created = true;
563
- }
564
- }
565
- else if (op.op === 'create') {
566
- slug = slugify(op.name);
567
- if (existsAnywhere(slug))
568
- errors.push(`Slug '${slug}' already exists`);
569
- if (op.kind === 'entity') {
570
- try {
571
- buildEntityFrontmatter(op.name, op.fields, existsAnywhere);
572
- }
573
- catch (err) {
574
- pushErr(err);
575
- }
576
- simulated.set(slug, { kind: 'entity', type: op.fields.type });
577
- }
578
- else {
579
- try {
580
- buildKnowledgeFrontmatter(op.name, (op.fields || {}), existsAnywhere);
581
- }
582
- catch (err) {
583
- pushErr(err);
584
- }
585
- simulated.set(slug, { kind: 'knowledge', type: 'knowledge' });
649
+ // Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
650
+ const ctx = {
651
+ resolve: (slug) => {
652
+ const sim = simulated.get(slug);
653
+ if (sim)
654
+ return sim;
655
+ const fp = findFile(slug);
656
+ if (!fp)
657
+ return null;
658
+ const kind = kindFromPath(fp);
659
+ if (kind === 'knowledge')
660
+ return { kind, type: 'knowledge' };
661
+ try {
662
+ const pf = parseFilePath(fp);
663
+ return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
586
664
  }
587
- created = errors.length === 0;
588
- }
589
- else if (op.op === 'capture') {
590
- slug = slugify(op.name);
591
- if (existsAnywhere(slug))
592
- errors.push(`Slug '${slug}' already exists`);
593
- simulated.set(slug, { kind: 'entity', type: 'capture' });
594
- created = errors.length === 0;
595
- }
596
- else if (op.op === 'link') {
597
- slug = op.slug;
598
- if (!resolve(op.slug))
599
- errors.push(`'${op.slug}' not found`);
600
- if (!existsAnywhere(op.target))
601
- errors.push(`Target '${op.target}' does not exist`);
602
- }
603
- else if (op.op === 'unlink') {
604
- slug = op.slug;
605
- if (!resolve(op.slug))
606
- errors.push(`'${op.slug}' not found`);
607
- }
608
- else {
609
- // promote
610
- slug = op.slug;
611
- const cur = resolve(op.slug);
612
- if (!cur)
613
- errors.push(`'${op.slug}' not found`);
614
- else {
615
- const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
616
- try {
617
- const contract = getContract(currentType);
618
- if (!contract.canPromoteTo.includes(op.target)) {
619
- errors.push(`Cannot promote ${currentType} to ${op.target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
620
- }
621
- else {
622
- simulated.set(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
623
- }
624
- }
625
- catch (err) {
626
- pushErr(err);
627
- }
665
+ catch {
666
+ return { kind, type: 'entity' };
628
667
  }
668
+ },
669
+ existsAnywhere,
670
+ sessionExists: (slug) => existsSync(join(vaultPath, kindDir('session'), `${slug}.md`)) || simulatedSessions.has(slug),
671
+ simulate: (slug, info) => { simulated.set(slug, info); },
672
+ simulateSession: (slug) => { simulatedSessions.add(slug); },
673
+ validateKindFields,
674
+ validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
675
+ buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
676
+ buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
677
+ };
678
+ const report = [];
679
+ for (const [index, op] of operations.entries()) {
680
+ // Shape and semantics in one pass: library callers get the same runtime
681
+ // guarantee the CLI's JSON path has always had.
682
+ const shape = BatchOperationSchema.safeParse(op);
683
+ if (!shape.success) {
684
+ const raw = op;
685
+ const label = raw && typeof raw.slug === 'string' ? raw.slug
686
+ : raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
687
+ report.push({
688
+ index,
689
+ op: typeof raw?.op === 'string' ? raw.op : '?',
690
+ slug: label,
691
+ errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
692
+ });
693
+ continue;
629
694
  }
630
- const entry = { index, op: op.op, slug, errors };
631
- if (created)
695
+ const spec = specFor(op);
696
+ const res = spec.check(op, ctx);
697
+ const entry = { index, op: op.op, slug: res.slug, errors: res.errors };
698
+ if (res.created)
632
699
  entry.created = true;
633
- if (warning)
634
- entry.warning = warning;
700
+ if (res.warning)
701
+ entry.warning = res.warning;
635
702
  report.push(entry);
636
703
  }
637
704
  return { valid: report.every(o => o.errors.length === 0), operations: report };
@@ -657,180 +724,20 @@ export function createHafez(config) {
657
724
  originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
658
725
  }
659
726
  }
660
- const modifiedFiles = [];
661
- const affectedSlugs = [];
727
+ const appliedOps = [];
662
728
  const results = [];
663
729
  let currentOp = -1;
664
730
  try {
665
731
  for (const op of operations) {
666
732
  currentOp++;
667
- if (op.op === 'update') {
668
- const filePath = findFile(op.slug);
669
- if (!filePath)
670
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
671
- captureOriginal(filePath);
672
- validateKindFields(kindFromPath(filePath), op.fields);
673
- const { frontmatter: fm, body } = parseFilePath(filePath);
674
- const applied = applyUpdateFields(fm, body, op.fields);
675
- applied.body = syncRelatedSection(applied.body, applied.fm);
676
- writeFileSync(filePath, serializeFile(applied.fm, applied.body));
677
- modifiedFiles.push(relative(vaultPath, filePath));
678
- affectedSlugs.push(op.slug);
679
- results.push({ op: 'update', slug: op.slug, status: 'ok' });
680
- }
681
- else if (op.op === 'create' && op.kind === 'session') {
682
- const slug = slugify(op.name);
683
- if (exists(slug)) {
684
- results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
685
- continue;
686
- }
687
- const sessionDir = join(vaultPath, 'sessions');
688
- mkdirSync(sessionDir, { recursive: true });
689
- const filePath = join(sessionDir, `${slug}.md`);
690
- if (existsSync(filePath)) {
691
- results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
692
- continue;
693
- }
694
- captureOriginal(filePath);
695
- const fm = { name: op.name, created: today() };
696
- const kf = (op.fields ?? {});
697
- if (kf['session-date'])
698
- fm['session-date'] = kf['session-date'];
699
- if (kf.related?.length)
700
- fm.related = kf.related;
701
- // Session files use sections: Summary, Entities Touched, Decisions
702
- const sections = ['Summary', 'Entities Touched', 'Decisions'];
703
- const lines = [];
704
- for (const section of sections) {
705
- lines.push(`## ${section}`);
706
- if (section === 'Summary' && kf.synthesis)
707
- lines.push('', kf.synthesis);
708
- lines.push('');
709
- }
710
- let sessionBody = lines.join('\n');
711
- sessionBody = syncRelatedSection(sessionBody, fm);
712
- writeFileSync(filePath, serializeFile(fm, sessionBody));
713
- modifiedFiles.push(relative(vaultPath, filePath));
714
- // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
715
- // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
716
- results.push({ op: 'create', slug, status: 'ok', created: true });
717
- }
718
- else if (op.op === 'create') {
719
- const slug = slugify(op.name);
720
- if (exists(slug))
721
- throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
722
- if (op.kind === 'entity') {
723
- const ef = op.fields;
724
- const fm = buildEntityFrontmatter(op.name, ef);
725
- let entBody = buildEntityBody(ef);
726
- const filePath = entityPath(slug);
727
- captureOriginal(filePath);
728
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
729
- entBody = syncRelatedSection(entBody, fm);
730
- writeFileSync(filePath, serializeFile(fm, entBody));
731
- modifiedFiles.push(relative(vaultPath, filePath));
732
- affectedSlugs.push(slug);
733
- results.push({ op: 'create', slug, status: 'ok', created: true });
734
- }
735
- else {
736
- const kf = (op.fields || {});
737
- const fm = buildKnowledgeFrontmatter(op.name, kf);
738
- let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
739
- const filePath = knowledgePath(slug);
740
- captureOriginal(filePath);
741
- mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
742
- body = syncRelatedSection(body, fm);
743
- writeFileSync(filePath, serializeFile(fm, body));
744
- modifiedFiles.push(relative(vaultPath, filePath));
745
- affectedSlugs.push(slug);
746
- results.push({ op: 'create', slug, status: 'ok', created: true });
747
- }
748
- }
749
- else if (op.op === 'capture') {
750
- const slug = slugify(op.name);
751
- if (exists(slug))
752
- throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
753
- const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
754
- const filePath = entityPath(slug);
755
- captureOriginal(filePath);
756
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
757
- writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
758
- modifiedFiles.push(relative(vaultPath, filePath));
759
- affectedSlugs.push(slug);
760
- results.push({ op: 'capture', slug, status: 'ok', created: true });
761
- }
762
- else if (op.op === 'link') {
763
- const filePath = findFile(op.slug);
764
- if (!filePath)
765
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
766
- if (!exists(op.target))
767
- throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
768
- captureOriginal(filePath);
769
- let { frontmatter: fm, body } = parseFilePath(filePath);
770
- if (op.relation === 'parent') {
771
- fm.parent = op.target;
772
- }
773
- else {
774
- const related = fm.related || [];
775
- if (!related.includes(op.target))
776
- related.push(op.target);
777
- fm.related = related;
778
- }
779
- body = syncRelatedSection(body, fm);
780
- writeFileSync(filePath, serializeFile(fm, body));
781
- modifiedFiles.push(relative(vaultPath, filePath));
782
- affectedSlugs.push(op.slug);
783
- results.push({ op: 'link', slug: op.slug, status: 'ok' });
784
- }
785
- else if (op.op === 'unlink') {
786
- const filePath = findFile(op.slug);
787
- if (!filePath)
788
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
789
- captureOriginal(filePath);
790
- let { frontmatter: fm, body } = parseFilePath(filePath);
791
- if (op.relation === 'parent' && fm.parent === op.target)
792
- delete fm.parent;
793
- else if (op.relation === 'related' && fm.related) {
794
- fm.related = fm.related.filter((r) => r !== op.target);
795
- if (fm.related.length === 0)
796
- delete fm.related;
797
- }
798
- body = syncRelatedSection(body, fm);
799
- writeFileSync(filePath, serializeFile(fm, body));
800
- modifiedFiles.push(relative(vaultPath, filePath));
801
- affectedSlugs.push(op.slug);
802
- results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
803
- }
804
- else if (op.op === 'promote') {
805
- const filePath = findFile(op.slug);
806
- if (!filePath)
807
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
808
- captureOriginal(filePath);
809
- if (op.target === 'knowledge') {
810
- captureOriginal(knowledgePath(op.slug));
811
- }
812
- const kind = kindFromPath(filePath);
813
- const { frontmatter: fm, body } = parseFilePath(filePath);
814
- const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
815
- modifiedFiles.push(...promoteFiles);
816
- affectedSlugs.push(op.slug);
817
- results.push({ op: 'promote', slug: op.slug, status: 'ok' });
818
- }
733
+ const a = applyOperation(op, captureOriginal);
734
+ results.push(a.result);
735
+ if (a.result.status === 'ok')
736
+ appliedOps.push(a);
819
737
  }
820
738
  }
821
739
  catch (err) {
822
- // Rollback all modified files
823
- for (const [path, content] of originals) {
824
- if (content === null) {
825
- try {
826
- unlinkSync(path);
827
- }
828
- catch { }
829
- }
830
- else {
831
- writeFileSync(path, content);
832
- }
833
- }
740
+ rollbackFiles(originals);
834
741
  // Localise: which op failed, by index and slug/name. The validate
835
742
  // phase above catches semantic errors pre-execution; this covers
836
743
  // residual execution-time failures (races, disk errors).
@@ -841,46 +748,35 @@ export function createHafez(config) {
841
748
  }
842
749
  throw err;
843
750
  }
844
- // Single git commit for all batch operations
845
- const uniqueFiles = [...new Set(modifiedFiles)];
751
+ // Single journal commit for all batch operations
752
+ const writtenAll = [...new Set(appliedOps.flatMap(a => a.filesWritten))];
753
+ const deletedAll = [...new Set(appliedOps.flatMap(a => a.filesDeleted))];
846
754
  const indexAfterBatch = () => {
847
- for (const slug of affectedSlugs) {
848
- const fp = findFile(slug);
849
- if (fp) {
850
- getIndex().upsertFromFile(slug, kindFromPath(fp));
851
- }
852
- }
755
+ updateIndexPostCommit(appliedOps);
853
756
  // Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
854
- const anyIndexedTouched = uniqueFiles.some(f => f.startsWith('knowledge/') || f.startsWith('entities/'));
757
+ const anyIndexedTouched = [...writtenAll, ...deletedAll].some(isIndexedRelPath);
855
758
  if (anyIndexedTouched) {
856
759
  regenerateIndexSafe();
857
760
  }
858
- for (const slug of affectedSlugs)
859
- onWrite?.(slug, 'batch');
761
+ // Session creates carry no index upserts — they were never notified
762
+ // pre-collapse either (sessions aren't indexed entities).
763
+ for (const a of appliedOps) {
764
+ if (a.indexUpserts.length > 0)
765
+ onWrite?.(a.result.slug, 'batch');
766
+ }
860
767
  };
861
- if (uniqueFiles.length > 0) {
768
+ if (writtenAll.length + deletedAll.length > 0) {
862
769
  try {
863
- await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
770
+ await journal.commit(writtenAll, deletedAll, `batch: ${operations.length} operations`);
864
771
  }
865
772
  catch (err) {
866
773
  if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
867
774
  // Commit-stage failure (e.g. a raced .git/index.lock): nothing was
868
- // committed — restore the captured originals so files, git, and
869
- // index stay consistent instead of leaving the batch half-applied.
870
- for (const [path, content] of originals) {
871
- if (content === null) {
872
- try {
873
- unlinkSync(path);
874
- }
875
- catch { }
876
- }
877
- else {
878
- writeFileSync(path, content);
879
- }
880
- }
881
- // Also clear anything the failed commit left staged, or the next
882
- // commit would publish the rolled-back content.
883
- await gitUnstage(vaultPath, uniqueFiles);
775
+ // committed — restore the captured originals so files, journal,
776
+ // and index stay consistent instead of leaving the batch
777
+ // half-applied. The adapter has already cleaned up its own
778
+ // partial state (staged files) before rethrowing.
779
+ rollbackFiles(originals);
884
780
  throw err;
885
781
  }
886
782
  // Push-stage failure: the batch commit exists locally ("changes
@@ -906,16 +802,16 @@ export function createHafez(config) {
906
802
  const knowledgeSlugs = new Set();
907
803
  const referencedKnowledge = new Set();
908
804
  // Scan entities
909
- const entDir = join(vaultPath, 'entities');
910
- if (existsSync(entDir)) {
911
- const files = readdirSync(entDir).filter(f => f.endsWith('.md'));
805
+ const entDir = join(vaultPath, kindDir('entity'));
806
+ {
807
+ const files = scanVaultDir(entDir);
912
808
  report.total_entities = files.length;
913
- for (const file of files) {
914
- const slug = basename(file, '.md');
809
+ for (const filePath of files) {
810
+ const slug = basename(filePath, '.md');
915
811
  allSlugs.add(slug);
916
812
  let fm;
917
813
  try {
918
- fm = parseFilePath(join(entDir, file)).frontmatter;
814
+ fm = parseFilePath(filePath).frontmatter;
919
815
  }
920
816
  catch {
921
817
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
@@ -939,17 +835,17 @@ export function createHafez(config) {
939
835
  }
940
836
  }
941
837
  // Scan knowledge
942
- const knDir = join(vaultPath, 'knowledge');
943
- if (existsSync(knDir)) {
944
- const files = readdirSync(knDir).filter(f => f.endsWith('.md'));
838
+ const knDir = join(vaultPath, kindDir('knowledge'));
839
+ {
840
+ const files = scanVaultDir(knDir);
945
841
  report.total_knowledge = files.length;
946
- for (const file of files) {
947
- const slug = basename(file, '.md');
842
+ for (const filePath of files) {
843
+ const slug = basename(filePath, '.md');
948
844
  allSlugs.add(slug);
949
845
  knowledgeSlugs.add(slug);
950
846
  let fm;
951
847
  try {
952
- fm = parseFilePath(join(knDir, file)).frontmatter;
848
+ fm = parseFilePath(filePath).frontmatter;
953
849
  }
954
850
  catch {
955
851
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
@@ -989,6 +885,7 @@ export function createHafez(config) {
989
885
  throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
990
886
  }
991
887
  const modifiedFiles = [];
888
+ const deletedFiles = [];
992
889
  if (target === 'knowledge') {
993
890
  // Cross-kind: move file from entities/ to knowledge/
994
891
  const newPath = knowledgePath(slug);
@@ -1013,8 +910,7 @@ export function createHafez(config) {
1013
910
  modifiedFiles.push(relative(vaultPath, newPath));
1014
911
  // The deletion must be staged too — `git add` of a removed path stages
1015
912
  // the removal; omitting it leaves the vault permanently dirty.
1016
- modifiedFiles.push(relative(vaultPath, filePath));
1017
- getIndex().removeItem(slug);
913
+ deletedFiles.push(relative(vaultPath, filePath));
1018
914
  }
1019
915
  else {
1020
916
  // Same-kind: capture→entity, capture→project, entity→project
@@ -1053,7 +949,7 @@ export function createHafez(config) {
1053
949
  writeFileSync(filePath, serializeFile(fm, newBody));
1054
950
  modifiedFiles.push(relative(vaultPath, filePath));
1055
951
  }
1056
- return { modifiedFiles };
952
+ return { modifiedFiles, deletedFiles };
1057
953
  }
1058
954
  async function promote(slug, target) {
1059
955
  if (readOnly)
@@ -1062,20 +958,24 @@ export function createHafez(config) {
1062
958
  const filePath = findFile(slug);
1063
959
  if (!filePath)
1064
960
  throw new HafezError('NOT_FOUND', `'${slug}' not found`);
1065
- const kind = kindFromPath(filePath);
1066
- // Capture original content for rollback before parsing (avoids double read)
961
+ // Capture original content for rollback
1067
962
  const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
1068
963
  const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
1069
- const { frontmatter: fm, body } = parseFilePath(filePath);
1070
- const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
964
+ let a;
1071
965
  try {
1072
- const { modifiedFiles } = promoteCore(slug, target, filePath, fm, body, kind);
1073
- await gitCommitAndPush(vaultPath, modifiedFiles, `promote: ${slug} ${sourceType} → ${target}`, gitConfig);
1074
- getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
1075
- regenerateIndexSafe();
1076
- onWrite?.(slug, 'promote');
966
+ a = applyOperation({ op: 'promote', slug, target });
967
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
1077
968
  }
1078
969
  catch (err) {
970
+ if (a && err instanceof HafezError && err.code === 'GIT_PUSH_FAILED') {
971
+ // Push-stage failure: the promote commit exists locally — keep the
972
+ // files and index them (same policy as batch); rolling back here
973
+ // would leave the worktree contradicting HEAD.
974
+ updateIndexPostCommit([a]);
975
+ regenerateIndexSafe();
976
+ throw err;
977
+ }
978
+ // Apply- or commit-stage failure: nothing was committed — restore.
1079
979
  if (target === 'knowledge' && originalContent) {
1080
980
  writeFileSync(filePath, originalContent);
1081
981
  // Never unlink the file we just restored (SEC-4)
@@ -1084,6 +984,9 @@ export function createHafez(config) {
1084
984
  }
1085
985
  throw err;
1086
986
  }
987
+ updateIndexPostCommit([a]);
988
+ regenerateIndexSafe();
989
+ onWrite?.(slug, 'promote');
1087
990
  return slug;
1088
991
  });
1089
992
  }
@@ -1118,7 +1021,7 @@ export function createHafez(config) {
1118
1021
  sync: async () => {
1119
1022
  if (readOnly)
1120
1023
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1121
- return withLock(() => gitSync(vaultPath));
1024
+ return withLock(() => journal.sync());
1122
1025
  },
1123
1026
  children: async (slug) => {
1124
1027
  const idx = getIndex();
@@ -1161,10 +1064,9 @@ export function createHafez(config) {
1161
1064
  s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
1162
1065
  return s;
1163
1066
  },
1164
- changelog: (since) => gitChangelog(vaultPath, since),
1067
+ changelog: (since) => journal.changelog(since),
1165
1068
  };
1166
1069
  }
1167
1070
  export { HafezError } from './types.js';
1168
- export { getBrief, getNextActions, findSection } from './sections.js';
1169
- export { parseSessionLogHeading, parseSessionLog } from './parser.js';
1170
- export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
1071
+ export { getBrief, getNextActions, findSection } from './document.js';
1072
+ export { parseSessionLogHeading, parseSessionLog } from './document.js';