hafez 1.1.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 } from './vault.js';
3
- import { gitCommitAndPush, gitSync, gitChangelog, gitUnstage } 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
@@ -71,9 +74,6 @@ export function createHafez(config) {
71
74
  }
72
75
  }
73
76
  function today() { return new Date().toISOString().slice(0, 10); }
74
- function kindFromPath(filePath) {
75
- return filePath.includes('/entities/') ? 'entity' : 'knowledge';
76
- }
77
77
  const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
78
78
  const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
79
79
  function validateKindFields(kind, fields) {
@@ -100,20 +100,22 @@ export function createHafez(config) {
100
100
  process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
101
101
  }
102
102
  }
103
- // Shared by create() and batch() so optional fields can't silently diverge
104
- // between the two paths (see CLAUDE.md Validation Gotchas).
105
- function buildEntityFrontmatter(name, ef) {
103
+ // Shared by create(), batch(), and the batch validate phase so optional
104
+ // fields can't silently diverge between paths (see CLAUDE.md Validation
105
+ // Gotchas). `existsFn` is pluggable so validateBatchCore can supply a
106
+ // simulation-aware check (same-batch creates count as existing).
107
+ function buildEntityFrontmatter(name, ef, existsFn = exists) {
106
108
  const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
107
109
  if (ef.domain?.length)
108
110
  fm.domain = ef.domain;
109
111
  if (ef.parent) {
110
- if (!exists(ef.parent))
112
+ if (!existsFn(ef.parent))
111
113
  throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
112
114
  fm.parent = ef.parent;
113
115
  }
114
116
  if (ef.related?.length) {
115
117
  for (const r of ef.related) {
116
- if (!exists(r))
118
+ if (!existsFn(r))
117
119
  throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
118
120
  }
119
121
  fm.related = ef.related;
@@ -129,13 +131,25 @@ export function createHafez(config) {
129
131
  throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
130
132
  return fm;
131
133
  }
132
- function buildKnowledgeFrontmatter(name, kf) {
134
+ // Shared by create() and batch() like buildEntityFrontmatter — body-side
135
+ // optional fields (brief, actions) must behave identically in both paths.
136
+ function buildEntityBody(ef) {
137
+ let body = bodyTemplate(ef.type, { purpose: ef.purpose });
138
+ if (ef.brief)
139
+ body = setBrief(body, ef.brief);
140
+ if (ef.add_action)
141
+ body = addNextAction(body, ef.add_action);
142
+ for (const action of ef.add_actions ?? [])
143
+ body = addNextAction(body, action);
144
+ return body;
145
+ }
146
+ function buildKnowledgeFrontmatter(name, kf, existsFn = exists) {
133
147
  const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
134
148
  if (kf.domain?.length)
135
149
  fm.domain = kf.domain;
136
150
  if (kf.related?.length) {
137
151
  for (const r of kf.related) {
138
- if (!exists(r))
152
+ if (!existsFn(r))
139
153
  throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
140
154
  }
141
155
  fm.related = kf.related;
@@ -153,50 +167,17 @@ export function createHafez(config) {
153
167
  fm['session-date'] = kf['session-date'];
154
168
  return fm;
155
169
  }
156
- function replaceSection(body, heading, content, insertBefore) {
157
- const idx = body.indexOf(heading);
158
- if (idx >= 0) {
159
- const afterPos = idx + heading.length;
160
- const nextH = findNextStructuralHeading(body, afterPos);
161
- if (nextH !== -1) {
162
- return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
163
- }
164
- return body.slice(0, afterPos) + '\n\n' + content + '\n';
165
- }
166
- if (insertBefore) {
167
- const beforeIdx = body.indexOf(insertBefore);
168
- if (beforeIdx >= 0) {
169
- return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
170
- }
171
- }
172
- return body + '\n\n' + heading + '\n\n' + content + '\n';
173
- }
174
- function insertSessionLog(body, entry) {
175
- const slHeader = '## Session Log';
176
- const slIdx = body.indexOf(slHeader);
177
- if (slIdx >= 0) {
178
- const insertPos = slIdx + slHeader.length;
179
- return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
180
- }
181
- return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
182
- }
183
- function appendToSection(body, heading, content) {
184
- const idx = body.indexOf(heading);
185
- if (idx >= 0) {
186
- const afterPos = idx + heading.length;
187
- const nextH = findNextStructuralHeading(body, afterPos);
188
- if (nextH !== -1) {
189
- return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
190
- }
191
- return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
192
- }
193
- return body + '\n\n' + heading + '\n\n' + content + '\n';
194
- }
195
170
  function applyUpdateFields(fm, body, fields) {
196
- 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
+ }
197
177
  fm.status = fields.status;
178
+ }
198
179
  if (fields.current_state !== undefined) {
199
- body = replaceSection(body, '## Current State', fields.current_state, '## Session Log');
180
+ body = setSection(body, 'Current State', fields.current_state, 'Session Log');
200
181
  }
201
182
  if (fields.brief !== undefined) {
202
183
  body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
@@ -225,7 +206,7 @@ export function createHafez(config) {
225
206
  const logErrors = validateSessionLogEntry(fields.session_log);
226
207
  if (logErrors.length > 0)
227
208
  throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
228
- body = insertSessionLog(body, formatSessionLogEntry(fields.session_log));
209
+ body = prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
229
210
  }
230
211
  // Domain (shared) + knowledge-only metadata
231
212
  if (fields.domain !== undefined) {
@@ -246,8 +227,12 @@ export function createHafez(config) {
246
227
  else
247
228
  fm.resource = fields.resource;
248
229
  }
249
- 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
+ }
250
234
  fm.confidence = fields.confidence;
235
+ }
251
236
  if (fields.tags !== undefined)
252
237
  fm.tags = fields.tags;
253
238
  if (fields.related !== undefined) {
@@ -258,18 +243,18 @@ export function createHafez(config) {
258
243
  fm.related = fields.related;
259
244
  }
260
245
  if (fields.synthesis !== undefined) {
261
- body = replaceSection(body, '## Synthesis', fields.synthesis);
246
+ body = setSection(body, 'Synthesis', fields.synthesis);
262
247
  }
263
248
  if (fields.add_evidence) {
264
249
  const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
265
- body = appendToSection(body, '## Evidence', sanitized);
250
+ body = appendToSection(body, 'Evidence', sanitized);
266
251
  fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
267
252
  fm['last-reinforced'] = today();
268
253
  // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
269
254
  // removed. Confidence changes are now a deliberate decision via update --confidence.
270
255
  }
271
256
  if (fields.add_source) {
272
- body = appendToSection(body, '## Sources', fields.add_source);
257
+ body = appendToSection(body, 'Sources', fields.add_source);
273
258
  }
274
259
  if (fm['last-touched'] !== today())
275
260
  fm['last-touched'] = today();
@@ -292,141 +277,323 @@ export function createHafez(config) {
292
277
  if (!filePath)
293
278
  throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
294
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;
295
282
  const frontmatter = parsed.frontmatter;
296
283
  const body = parsed.body;
297
284
  if (depth === 'frontmatter')
298
- return { frontmatter, body: '' };
285
+ return { kind, frontmatter, body: '' };
299
286
  if (depth === 'summary') {
300
- const sessionLogIdx = body.indexOf('## Session Log');
301
- return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
287
+ return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
302
288
  }
303
- return { frontmatter, body };
289
+ return { kind, frontmatter, body };
304
290
  }
305
- async function create(kind, name, fields) {
306
- if (readOnly)
307
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
308
- return withLock(async () => {
309
- 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);
310
412
  if (exists(slug))
311
413
  throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
312
- if (kind === 'entity') {
313
- const ef = fields;
314
- const fm = buildEntityFrontmatter(name, ef);
315
- let body = bodyTemplate(ef.type, { purpose: ef.purpose });
316
- if (ef.brief)
317
- body = setBrief(body, ef.brief);
318
- if (ef.add_action)
319
- body = addNextAction(body, ef.add_action);
414
+ if (op.kind === 'entity') {
415
+ const ef = op.fields;
416
+ const fm = buildEntityFrontmatter(op.name, ef);
417
+ let body = buildEntityBody(ef);
320
418
  const filePath = entityPath(slug);
321
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
322
- body = syncRelatedSection(body, fm);
323
- writeFileSync(filePath, serializeFile(fm, body));
324
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
325
- getIndex().upsertFromFile(slug, 'entity');
326
- onWrite?.(slug, 'create');
327
- }
328
- else {
329
- const kf = (fields || {});
330
- const fm = buildKnowledgeFrontmatter(name, kf);
331
- let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
332
- const filePath = knowledgePath(slug);
333
- mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
419
+ captureOriginal(filePath);
420
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
334
421
  body = syncRelatedSection(body, fm);
335
422
  writeFileSync(filePath, serializeFile(fm, body));
336
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
337
- getIndex().upsertFromFile(slug, 'knowledge');
338
- 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
+ };
339
431
  }
340
- regenerateIndexSafe();
341
- return slug;
342
- });
343
- }
344
- async function capture(name, notes) {
345
- if (readOnly)
346
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
347
- return withLock(async () => {
348
- 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);
349
451
  if (exists(slug))
350
452
  throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
351
453
  const fm = {
352
- name,
454
+ name: op.name,
353
455
  type: 'capture',
354
456
  status: 'active',
355
457
  created: today(),
356
458
  'last-touched': today(),
357
459
  };
358
- const body = bodyTemplate('capture', { notes });
359
460
  const filePath = entityPath(slug);
360
- 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);
493
+ writeFileSync(filePath, serializeFile(fm, body));
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);
361
520
  writeFileSync(filePath, serializeFile(fm, body));
362
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `capture: ${name}`, gitConfig);
363
- getIndex().upsertFromFile(slug, 'entity');
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');
364
562
  regenerateIndexSafe();
365
- onWrite?.(slug, 'capture');
366
- 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;
367
576
  });
368
577
  }
369
578
  async function update(slug, fields) {
370
579
  if (readOnly)
371
580
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
372
581
  return withLock(async () => {
373
- const filePath = findFile(slug);
374
- if (!filePath)
375
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
376
- const kind = kindFromPath(filePath);
377
- validateKindFields(kind, fields);
378
- let { frontmatter: fm, body } = parseFilePath(filePath);
379
- const applied = applyUpdateFields(fm, body, fields);
380
- fm = applied.fm;
381
- body = applied.body;
382
- // Session log archival
383
- if (countSessionLogEntries(body) >= 10) {
384
- const extracted = extractOldestSessionLogEntry(body);
385
- if (extracted) {
386
- const archiveDir = join(vaultPath, 'entities', 'archive');
387
- mkdirSync(archiveDir, { recursive: true });
388
- const archivePath = join(archiveDir, `${slug}-log.md`);
389
- appendFileSync(archivePath, extracted.entry + '\n\n');
390
- body = extracted.remaining;
391
- }
392
- }
393
- body = syncRelatedSection(body, fm);
394
- writeFileSync(filePath, serializeFile(fm, body));
395
- const files = [relative(vaultPath, filePath)];
396
- const archivePath = join(vaultPath, 'entities', 'archive', `${slug}-log.md`);
397
- if (existsSync(archivePath))
398
- files.push(relative(vaultPath, archivePath));
399
- await gitCommitAndPush(vaultPath, files, `update: ${slug}`, gitConfig);
400
- 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]);
401
585
  regenerateIndexSafe();
402
586
  onWrite?.(slug, 'update');
403
- return { matched_action: applied.matchedAction };
587
+ return { matched_action: a.matchedAction };
404
588
  });
405
589
  }
406
590
  async function link(slug, target, relation) {
407
591
  if (readOnly)
408
592
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
409
593
  return withLock(async () => {
410
- const filePath = findFile(slug);
411
- if (!filePath)
412
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
413
- if (!exists(target))
414
- throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
415
- let { frontmatter: fm, body } = parseFilePath(filePath);
416
- if (relation === 'parent') {
417
- fm.parent = target;
418
- }
419
- else {
420
- const related = fm.related || [];
421
- if (!related.includes(target))
422
- related.push(target);
423
- fm.related = related;
424
- }
425
- body = syncRelatedSection(body, fm);
426
- writeFileSync(filePath, serializeFile(fm, body));
427
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `link: ${slug} → ${target} (${relation})`, gitConfig);
428
- const kind = kindFromPath(filePath);
429
- 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]);
430
597
  regenerateIndexSafe();
431
598
  onWrite?.(slug, 'link');
432
599
  });
@@ -435,31 +602,121 @@ export function createHafez(config) {
435
602
  if (readOnly)
436
603
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
437
604
  return withLock(async () => {
438
- const filePath = findFile(slug);
439
- if (!filePath)
440
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
441
- let { frontmatter: fm, body } = parseFilePath(filePath);
442
- if (relation === 'parent' && fm.parent === target) {
443
- delete fm.parent;
444
- }
445
- else if (relation === 'related' && fm.related) {
446
- fm.related = fm.related.filter((r) => r !== target);
447
- if (fm.related.length === 0)
448
- delete fm.related;
449
- }
450
- body = syncRelatedSection(body, fm);
451
- writeFileSync(filePath, serializeFile(fm, body));
452
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
453
- const kind = kindFromPath(filePath);
454
- 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]);
455
608
  regenerateIndexSafe();
456
609
  onWrite?.(slug, 'unlink');
457
610
  });
458
611
  }
612
+ // "Recent" with day-granularity dates: primary sort is the frontmatter day,
613
+ // ties break on git commit time (the real recency signal), then name for
614
+ // files with no commit history yet. Without this the top-5 lists leak
615
+ // SQLite row order — a brand-new vault (everything same-day) showed
616
+ // alphabetical-and-missing-newest lists.
617
+ async function rankRecents(rows, dateKey, mode) {
618
+ const day = (r) => r[dateKey];
619
+ if (rows.length <= 1)
620
+ return rows;
621
+ // Everything sharing the 5th row's day (or newer) could land in the top 5
622
+ const cutoffDay = day(rows[Math.min(4, rows.length - 1)]);
623
+ const candidates = rows.filter(r => day(r) >= cutoffDay);
624
+ const hasTies = new Set(candidates.map(day)).size < candidates.length;
625
+ if (!hasTies)
626
+ return rows.slice(0, 5);
627
+ const times = await journal.fileTimes(mode);
628
+ const gitTime = (r) => times.get(`${kindDir('entity')}/${r.slug}.md`) ?? '';
629
+ candidates.sort((a, b) => {
630
+ const dayCmp = day(b).localeCompare(day(a));
631
+ if (dayCmp !== 0)
632
+ return dayCmp;
633
+ const gitCmp = gitTime(b).localeCompare(gitTime(a)); // '' (uncommitted) sorts last
634
+ if (gitCmp !== 0)
635
+ return gitCmp;
636
+ return a.name.localeCompare(b.name);
637
+ });
638
+ return candidates.slice(0, 5);
639
+ }
640
+ // Shared validate phase — dry-run and apply both run this, so dry-run can
641
+ // never pass a payload apply rejects (parity by construction). Simulates
642
+ // creates so same-batch references validate: batch executes sequentially,
643
+ // so op N really does see op 1's creates on disk at apply time.
644
+ function validateBatchCore(operations) {
645
+ // slug → what it would be after earlier ops in this batch
646
+ const simulated = new Map();
647
+ const simulatedSessions = new Set();
648
+ const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
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' };
664
+ }
665
+ catch {
666
+ return { kind, type: 'entity' };
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;
694
+ }
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)
699
+ entry.created = true;
700
+ if (res.warning)
701
+ entry.warning = res.warning;
702
+ report.push(entry);
703
+ }
704
+ return { valid: report.every(o => o.errors.length === 0), operations: report };
705
+ }
706
+ function formatOpErrors(report) {
707
+ return report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
708
+ }
459
709
  async function batch(operations) {
460
710
  if (readOnly)
461
711
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
462
712
  return withLock(async () => {
713
+ // Validate the whole payload before touching anything — all errors
714
+ // surface at once, localised to their op.
715
+ const validation = validateBatchCore(operations);
716
+ if (!validation.valid) {
717
+ const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
718
+ throw new HafezError('VALIDATION_FAILED', `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${operations.length} applied)`, formatOpErrors(validation));
719
+ }
463
720
  // Capture originals for rollback
464
721
  const originals = new Map();
465
722
  function captureOriginal(filePath) {
@@ -467,224 +724,59 @@ export function createHafez(config) {
467
724
  originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
468
725
  }
469
726
  }
470
- const modifiedFiles = [];
471
- const affectedSlugs = [];
727
+ const appliedOps = [];
472
728
  const results = [];
729
+ let currentOp = -1;
473
730
  try {
474
731
  for (const op of operations) {
475
- if (op.op === 'update') {
476
- const filePath = findFile(op.slug);
477
- if (!filePath)
478
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
479
- captureOriginal(filePath);
480
- validateKindFields(kindFromPath(filePath), op.fields);
481
- const { frontmatter: fm, body } = parseFilePath(filePath);
482
- const applied = applyUpdateFields(fm, body, op.fields);
483
- applied.body = syncRelatedSection(applied.body, applied.fm);
484
- writeFileSync(filePath, serializeFile(applied.fm, applied.body));
485
- modifiedFiles.push(relative(vaultPath, filePath));
486
- affectedSlugs.push(op.slug);
487
- results.push({ op: 'update', slug: op.slug, status: 'ok' });
488
- }
489
- else if (op.op === 'create' && op.kind === 'session') {
490
- const slug = slugify(op.name);
491
- if (exists(slug)) {
492
- results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
493
- continue;
494
- }
495
- const sessionDir = join(vaultPath, 'sessions');
496
- mkdirSync(sessionDir, { recursive: true });
497
- const filePath = join(sessionDir, `${slug}.md`);
498
- if (existsSync(filePath)) {
499
- results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
500
- continue;
501
- }
502
- captureOriginal(filePath);
503
- const fm = { name: op.name, created: today() };
504
- const kf = (op.fields ?? {});
505
- if (kf['session-date'])
506
- fm['session-date'] = kf['session-date'];
507
- if (kf.related?.length)
508
- fm.related = kf.related;
509
- // Session files use sections: Summary, Entities Touched, Decisions
510
- const sections = ['Summary', 'Entities Touched', 'Decisions'];
511
- const lines = [];
512
- for (const section of sections) {
513
- lines.push(`## ${section}`);
514
- if (section === 'Summary' && kf.synthesis)
515
- lines.push('', kf.synthesis);
516
- lines.push('');
517
- }
518
- let sessionBody = lines.join('\n');
519
- sessionBody = syncRelatedSection(sessionBody, fm);
520
- writeFileSync(filePath, serializeFile(fm, sessionBody));
521
- modifiedFiles.push(relative(vaultPath, filePath));
522
- // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
523
- // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
524
- results.push({ op: 'create', slug, status: 'ok', created: true });
525
- }
526
- else if (op.op === 'create') {
527
- const slug = slugify(op.name);
528
- if (exists(slug))
529
- throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
530
- if (op.kind === 'entity') {
531
- const ef = op.fields;
532
- const fm = buildEntityFrontmatter(op.name, ef);
533
- let entBody = bodyTemplate(ef.type, { purpose: ef.purpose });
534
- if (ef.brief)
535
- entBody = setBrief(entBody, ef.brief);
536
- if (ef.add_action)
537
- entBody = addNextAction(entBody, ef.add_action);
538
- const filePath = entityPath(slug);
539
- captureOriginal(filePath);
540
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
541
- entBody = syncRelatedSection(entBody, fm);
542
- writeFileSync(filePath, serializeFile(fm, entBody));
543
- modifiedFiles.push(relative(vaultPath, filePath));
544
- affectedSlugs.push(slug);
545
- results.push({ op: 'create', slug, status: 'ok', created: true });
546
- }
547
- else {
548
- const kf = (op.fields || {});
549
- const fm = buildKnowledgeFrontmatter(op.name, kf);
550
- let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
551
- const filePath = knowledgePath(slug);
552
- captureOriginal(filePath);
553
- mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
554
- body = syncRelatedSection(body, fm);
555
- writeFileSync(filePath, serializeFile(fm, body));
556
- modifiedFiles.push(relative(vaultPath, filePath));
557
- affectedSlugs.push(slug);
558
- results.push({ op: 'create', slug, status: 'ok', created: true });
559
- }
560
- }
561
- else if (op.op === 'capture') {
562
- const slug = slugify(op.name);
563
- if (exists(slug))
564
- throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
565
- const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
566
- const filePath = entityPath(slug);
567
- captureOriginal(filePath);
568
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
569
- writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
570
- modifiedFiles.push(relative(vaultPath, filePath));
571
- affectedSlugs.push(slug);
572
- results.push({ op: 'capture', slug, status: 'ok', created: true });
573
- }
574
- else if (op.op === 'link') {
575
- const filePath = findFile(op.slug);
576
- if (!filePath)
577
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
578
- if (!exists(op.target))
579
- throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
580
- captureOriginal(filePath);
581
- let { frontmatter: fm, body } = parseFilePath(filePath);
582
- if (op.relation === 'parent') {
583
- fm.parent = op.target;
584
- }
585
- else {
586
- const related = fm.related || [];
587
- if (!related.includes(op.target))
588
- related.push(op.target);
589
- fm.related = related;
590
- }
591
- body = syncRelatedSection(body, fm);
592
- writeFileSync(filePath, serializeFile(fm, body));
593
- modifiedFiles.push(relative(vaultPath, filePath));
594
- affectedSlugs.push(op.slug);
595
- results.push({ op: 'link', slug: op.slug, status: 'ok' });
596
- }
597
- else if (op.op === 'unlink') {
598
- const filePath = findFile(op.slug);
599
- if (!filePath)
600
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
601
- captureOriginal(filePath);
602
- let { frontmatter: fm, body } = parseFilePath(filePath);
603
- if (op.relation === 'parent' && fm.parent === op.target)
604
- delete fm.parent;
605
- else if (op.relation === 'related' && fm.related) {
606
- fm.related = fm.related.filter((r) => r !== op.target);
607
- if (fm.related.length === 0)
608
- delete fm.related;
609
- }
610
- body = syncRelatedSection(body, fm);
611
- writeFileSync(filePath, serializeFile(fm, body));
612
- modifiedFiles.push(relative(vaultPath, filePath));
613
- affectedSlugs.push(op.slug);
614
- results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
615
- }
616
- else if (op.op === 'promote') {
617
- const filePath = findFile(op.slug);
618
- if (!filePath)
619
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
620
- captureOriginal(filePath);
621
- if (op.target === 'knowledge') {
622
- captureOriginal(knowledgePath(op.slug));
623
- }
624
- const kind = kindFromPath(filePath);
625
- const { frontmatter: fm, body } = parseFilePath(filePath);
626
- const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
627
- modifiedFiles.push(...promoteFiles);
628
- affectedSlugs.push(op.slug);
629
- results.push({ op: 'promote', slug: op.slug, status: 'ok' });
630
- }
732
+ currentOp++;
733
+ const a = applyOperation(op, captureOriginal);
734
+ results.push(a.result);
735
+ if (a.result.status === 'ok')
736
+ appliedOps.push(a);
631
737
  }
632
738
  }
633
739
  catch (err) {
634
- // Rollback all modified files
635
- for (const [path, content] of originals) {
636
- if (content === null) {
637
- try {
638
- unlinkSync(path);
639
- }
640
- catch { }
641
- }
642
- else {
643
- writeFileSync(path, content);
644
- }
740
+ rollbackFiles(originals);
741
+ // Localise: which op failed, by index and slug/name. The validate
742
+ // phase above catches semantic errors pre-execution; this covers
743
+ // residual execution-time failures (races, disk errors).
744
+ if (err instanceof HafezError && currentOp >= 0) {
745
+ const op = operations[currentOp];
746
+ const label = 'slug' in op ? op.slug : slugify(op.name);
747
+ throw new HafezError(err.code, `op[${currentOp}] (${op.op} ${label}): ${err.message}`, err.details);
645
748
  }
646
749
  throw err;
647
750
  }
648
- // Single git commit for all batch operations
649
- 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))];
650
754
  const indexAfterBatch = () => {
651
- for (const slug of affectedSlugs) {
652
- const fp = findFile(slug);
653
- if (fp) {
654
- getIndex().upsertFromFile(slug, kindFromPath(fp));
655
- }
656
- }
755
+ updateIndexPostCommit(appliedOps);
657
756
  // Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
658
- const anyIndexedTouched = uniqueFiles.some(f => f.startsWith('knowledge/') || f.startsWith('entities/'));
757
+ const anyIndexedTouched = [...writtenAll, ...deletedAll].some(isIndexedRelPath);
659
758
  if (anyIndexedTouched) {
660
759
  regenerateIndexSafe();
661
760
  }
662
- for (const slug of affectedSlugs)
663
- 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
+ }
664
767
  };
665
- if (uniqueFiles.length > 0) {
768
+ if (writtenAll.length + deletedAll.length > 0) {
666
769
  try {
667
- await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
770
+ await journal.commit(writtenAll, deletedAll, `batch: ${operations.length} operations`);
668
771
  }
669
772
  catch (err) {
670
773
  if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
671
774
  // Commit-stage failure (e.g. a raced .git/index.lock): nothing was
672
- // committed — restore the captured originals so files, git, and
673
- // index stay consistent instead of leaving the batch half-applied.
674
- for (const [path, content] of originals) {
675
- if (content === null) {
676
- try {
677
- unlinkSync(path);
678
- }
679
- catch { }
680
- }
681
- else {
682
- writeFileSync(path, content);
683
- }
684
- }
685
- // Also clear anything the failed commit left staged, or the next
686
- // commit would publish the rolled-back content.
687
- 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);
688
780
  throw err;
689
781
  }
690
782
  // Push-stage failure: the batch commit exists locally ("changes
@@ -710,16 +802,16 @@ export function createHafez(config) {
710
802
  const knowledgeSlugs = new Set();
711
803
  const referencedKnowledge = new Set();
712
804
  // Scan entities
713
- const entDir = join(vaultPath, 'entities');
714
- if (existsSync(entDir)) {
715
- const files = readdirSync(entDir).filter(f => f.endsWith('.md'));
805
+ const entDir = join(vaultPath, kindDir('entity'));
806
+ {
807
+ const files = scanVaultDir(entDir);
716
808
  report.total_entities = files.length;
717
- for (const file of files) {
718
- const slug = basename(file, '.md');
809
+ for (const filePath of files) {
810
+ const slug = basename(filePath, '.md');
719
811
  allSlugs.add(slug);
720
812
  let fm;
721
813
  try {
722
- fm = parseFilePath(join(entDir, file)).frontmatter;
814
+ fm = parseFilePath(filePath).frontmatter;
723
815
  }
724
816
  catch {
725
817
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
@@ -743,17 +835,17 @@ export function createHafez(config) {
743
835
  }
744
836
  }
745
837
  // Scan knowledge
746
- const knDir = join(vaultPath, 'knowledge');
747
- if (existsSync(knDir)) {
748
- const files = readdirSync(knDir).filter(f => f.endsWith('.md'));
838
+ const knDir = join(vaultPath, kindDir('knowledge'));
839
+ {
840
+ const files = scanVaultDir(knDir);
749
841
  report.total_knowledge = files.length;
750
- for (const file of files) {
751
- const slug = basename(file, '.md');
842
+ for (const filePath of files) {
843
+ const slug = basename(filePath, '.md');
752
844
  allSlugs.add(slug);
753
845
  knowledgeSlugs.add(slug);
754
846
  let fm;
755
847
  try {
756
- fm = parseFilePath(join(knDir, file)).frontmatter;
848
+ fm = parseFilePath(filePath).frontmatter;
757
849
  }
758
850
  catch {
759
851
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
@@ -793,6 +885,7 @@ export function createHafez(config) {
793
885
  throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
794
886
  }
795
887
  const modifiedFiles = [];
888
+ const deletedFiles = [];
796
889
  if (target === 'knowledge') {
797
890
  // Cross-kind: move file from entities/ to knowledge/
798
891
  const newPath = knowledgePath(slug);
@@ -817,8 +910,7 @@ export function createHafez(config) {
817
910
  modifiedFiles.push(relative(vaultPath, newPath));
818
911
  // The deletion must be staged too — `git add` of a removed path stages
819
912
  // the removal; omitting it leaves the vault permanently dirty.
820
- modifiedFiles.push(relative(vaultPath, filePath));
821
- getIndex().removeItem(slug);
913
+ deletedFiles.push(relative(vaultPath, filePath));
822
914
  }
823
915
  else {
824
916
  // Same-kind: capture→entity, capture→project, entity→project
@@ -857,7 +949,7 @@ export function createHafez(config) {
857
949
  writeFileSync(filePath, serializeFile(fm, newBody));
858
950
  modifiedFiles.push(relative(vaultPath, filePath));
859
951
  }
860
- return { modifiedFiles };
952
+ return { modifiedFiles, deletedFiles };
861
953
  }
862
954
  async function promote(slug, target) {
863
955
  if (readOnly)
@@ -866,20 +958,24 @@ export function createHafez(config) {
866
958
  const filePath = findFile(slug);
867
959
  if (!filePath)
868
960
  throw new HafezError('NOT_FOUND', `'${slug}' not found`);
869
- const kind = kindFromPath(filePath);
870
- // Capture original content for rollback before parsing (avoids double read)
961
+ // Capture original content for rollback
871
962
  const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
872
963
  const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
873
- const { frontmatter: fm, body } = parseFilePath(filePath);
874
- const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
964
+ let a;
875
965
  try {
876
- const { modifiedFiles } = promoteCore(slug, target, filePath, fm, body, kind);
877
- await gitCommitAndPush(vaultPath, modifiedFiles, `promote: ${slug} ${sourceType} → ${target}`, gitConfig);
878
- getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
879
- regenerateIndexSafe();
880
- onWrite?.(slug, 'promote');
966
+ a = applyOperation({ op: 'promote', slug, target });
967
+ await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
881
968
  }
882
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.
883
979
  if (target === 'knowledge' && originalContent) {
884
980
  writeFileSync(filePath, originalContent);
885
981
  // Never unlink the file we just restored (SEC-4)
@@ -888,6 +984,9 @@ export function createHafez(config) {
888
984
  }
889
985
  throw err;
890
986
  }
987
+ updateIndexPostCommit([a]);
988
+ regenerateIndexSafe();
989
+ onWrite?.(slug, 'promote');
891
990
  return slug;
892
991
  });
893
992
  }
@@ -918,10 +1017,11 @@ export function createHafez(config) {
918
1017
  link,
919
1018
  unlink,
920
1019
  batch,
1020
+ validateBatch: async (operations) => validateBatchCore(operations),
921
1021
  sync: async () => {
922
1022
  if (readOnly)
923
1023
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
924
- return withLock(() => gitSync(vaultPath));
1024
+ return withLock(() => journal.sync());
925
1025
  },
926
1026
  children: async (slug) => {
927
1027
  const idx = getIndex();
@@ -959,12 +1059,14 @@ export function createHafez(config) {
959
1059
  if (!idx)
960
1060
  return emptyStats;
961
1061
  idx.syncIfStale();
962
- return idx.getStats();
1062
+ const s = idx.getStats();
1063
+ s.recently_touched = await rankRecents(s.recently_touched, 'last_touched', 'modified');
1064
+ s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
1065
+ return s;
963
1066
  },
964
- changelog: (since) => gitChangelog(vaultPath, since),
1067
+ changelog: (since) => journal.changelog(since),
965
1068
  };
966
1069
  }
967
1070
  export { HafezError } from './types.js';
968
- export { getBrief, getNextActions, findSection } from './sections.js';
969
- export { parseSessionLogHeading, parseSessionLog } from './parser.js';
970
- export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
1071
+ export { getBrief, getNextActions, findSection } from './document.js';
1072
+ export { parseSessionLogHeading, parseSessionLog } from './document.js';