hafez 1.3.0 → 1.4.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,17 +1,15 @@
1
1
  // src/index.ts
2
- import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, scanVaultDir, archiveLogPath, isIndexedRelPath } from './vault.js';
2
+ import { parseFilePath, slugify, kindFromPath, kindDir, scanVaultDir, isIndexedRelPath, findVaultFile, vaultFileExists } from './vault.js';
3
3
  import { createGitJournal } from './git.js';
4
4
  import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
5
- import { bodyTemplate, knowledgeBodyTemplate } from './templates.js';
6
- import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry, setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findSection, setSection, appendToSection, prependSessionLogEntry, bodyBeforeSessionLog } from './document.js';
7
- import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
8
- import { getContract, ENTITY_STATUSES, CONFIDENCE_LEVELS } from './contracts.js';
5
+ import { documentForFile } from './document.js';
9
6
  import { createIndex } from './db.js';
10
7
  import { generateVaultIndex } from './knowledge-index.js';
11
8
  import { HafezError } from './types.js';
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';
9
+ import { BatchOperationSchema, specFor, slugBearingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
10
+ import { applyOperation, buildEntityFrontmatter, buildKnowledgeFrontmatter, validateKindFields } from './apply.js';
11
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
12
+ import { join, basename } from 'path';
15
13
  import { lock as lockVault } from 'proper-lockfile';
16
14
  export function createHafez(config) {
17
15
  const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
@@ -51,215 +49,28 @@ export function createHafez(config) {
51
49
  catch { /* stale lock reclaimed by another process */ }
52
50
  }
53
51
  }
54
- function entityPath(slug) { return resolveFilePath(vaultPath, slug, 'entity'); }
55
- function knowledgePath(slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
56
- function findFile(slug) {
57
- const ep = entityPath(slug);
58
- if (existsSync(ep))
59
- return ep;
60
- const kp = knowledgePath(slug);
61
- if (existsSync(kp))
62
- return kp;
63
- return null;
64
- }
65
- function exists(slug) {
66
- // An invalid slug definitionally doesn't exist — reference checks (create
67
- // parent/related, link targets, validate) must report "does not exist",
68
- // not crash. validate() in particular exists to REPORT malformed refs.
69
- try {
70
- return findFile(slug) !== null;
71
- }
72
- catch {
73
- return false;
74
- }
75
- }
76
- function today() { return new Date().toISOString().slice(0, 10); }
77
- const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
78
- const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
79
- function validateKindFields(kind, fields) {
80
- if (kind === 'knowledge') {
81
- const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined);
82
- if (invalid.length > 0) {
83
- throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
84
- }
85
- }
86
- if (kind === 'entity') {
87
- const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
88
- if (invalid.length > 0) {
89
- throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
90
- }
91
- }
92
- }
52
+ // Layout answers where a slug lives; these bind the vault path once.
53
+ function findFile(slug) { return findVaultFile(vaultPath, slug); }
54
+ function exists(slug) { return vaultFileExists(vaultPath, slug); }
93
55
  function regenerateIndexSafe() {
94
56
  // Runs post-commit: the vault write is already persisted/pushed, so an
95
57
  // index.md failure must warn, never reject the operation.
96
58
  try {
97
- generateVaultIndex(vaultPath);
59
+ // index.md is rendered from the rows updateIndexPostCommit just
60
+ // refreshed, not from a second full-vault read: everything it shows
61
+ // (name, status, type, confidence, description, domain, body-derived
62
+ // summary) is already indexed. syncIfStale first so files changed
63
+ // outside hafez — a hand edit, a git pull — are represented too; that
64
+ // costs a stat per file, where the old path paid a parse per file on
65
+ // every single mutation.
66
+ const idx = getIndex();
67
+ idx?.syncIfStale();
68
+ generateVaultIndex(vaultPath, idx?.indexEntries());
98
69
  }
99
70
  catch (err) {
100
71
  process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
101
72
  }
102
73
  }
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) {
108
- const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
109
- if (ef.domain?.length)
110
- fm.domain = ef.domain;
111
- if (ef.parent) {
112
- if (!existsFn(ef.parent))
113
- throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
114
- fm.parent = ef.parent;
115
- }
116
- if (ef.related?.length) {
117
- for (const r of ef.related) {
118
- if (!existsFn(r))
119
- throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
120
- }
121
- fm.related = ef.related;
122
- }
123
- if (ef.tags?.length)
124
- fm.tags = ef.tags;
125
- if (ef.description)
126
- fm.description = ef.description;
127
- if (ef.resource)
128
- fm.resource = ef.resource;
129
- const errors = validateEntityFrontmatter(fm);
130
- if (errors.length > 0)
131
- throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
132
- return fm;
133
- }
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) {
147
- const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
148
- if (kf.domain?.length)
149
- fm.domain = kf.domain;
150
- if (kf.related?.length) {
151
- for (const r of kf.related) {
152
- if (!existsFn(r))
153
- throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
154
- }
155
- fm.related = kf.related;
156
- }
157
- if (kf.tags?.length)
158
- fm.tags = kf.tags;
159
- if (kf.description)
160
- fm.description = kf.description;
161
- const errors = validateKnowledgeFrontmatter(fm);
162
- if (errors.length > 0)
163
- throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
164
- if (kf.subtype)
165
- fm.subtype = kf.subtype;
166
- if (kf['session-date'])
167
- fm['session-date'] = kf['session-date'];
168
- return fm;
169
- }
170
- function applyUpdateFields(fm, body, fields) {
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
- }
177
- fm.status = fields.status;
178
- }
179
- if (fields.current_state !== undefined) {
180
- body = setSection(body, 'Current State', fields.current_state, 'Session Log');
181
- }
182
- if (fields.brief !== undefined) {
183
- body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
184
- }
185
- let matchedAction;
186
- if (fields.add_action)
187
- body = addNextAction(body, fields.add_action);
188
- if (fields.add_actions) {
189
- for (const action of fields.add_actions) {
190
- body = addNextAction(body, action);
191
- }
192
- }
193
- if (fields.complete_action) {
194
- const r = completeNextAction(body, fields.complete_action);
195
- body = r.body;
196
- matchedAction = r.matched;
197
- }
198
- if (fields.remove_action) {
199
- const r = removeNextAction(body, fields.remove_action);
200
- body = r.body;
201
- matchedAction = r.matched;
202
- }
203
- if (fields.clear_actions)
204
- body = clearNextActions(body);
205
- if (fields.session_log) {
206
- const logErrors = validateSessionLogEntry(fields.session_log);
207
- if (logErrors.length > 0)
208
- throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
209
- body = prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
210
- }
211
- // Domain (shared) + knowledge-only metadata
212
- if (fields.domain !== undefined) {
213
- if (fields.domain.length === 0)
214
- delete fm.domain;
215
- else
216
- fm.domain = fields.domain;
217
- }
218
- if (fields.description !== undefined) {
219
- if (fields.description === '' || fields.description === null)
220
- delete fm.description;
221
- else
222
- fm.description = fields.description;
223
- }
224
- if (fields.resource !== undefined) {
225
- if (fields.resource === '' || fields.resource === null)
226
- delete fm.resource;
227
- else
228
- fm.resource = fields.resource;
229
- }
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
- }
234
- fm.confidence = fields.confidence;
235
- }
236
- if (fields.tags !== undefined)
237
- fm.tags = fields.tags;
238
- if (fields.related !== undefined) {
239
- for (const r of fields.related) {
240
- if (!exists(r))
241
- throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
242
- }
243
- fm.related = fields.related;
244
- }
245
- if (fields.synthesis !== undefined) {
246
- body = setSection(body, 'Synthesis', fields.synthesis);
247
- }
248
- if (fields.add_evidence) {
249
- const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
250
- body = appendToSection(body, 'Evidence', sanitized);
251
- fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
252
- fm['last-reinforced'] = today();
253
- // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
254
- // removed. Confidence changes are now a deliberate decision via update --confidence.
255
- }
256
- if (fields.add_source) {
257
- body = appendToSection(body, 'Sources', fields.add_source);
258
- }
259
- if (fm['last-touched'] !== today())
260
- fm['last-touched'] = today();
261
- return { fm, body, matchedAction };
262
- }
263
74
  let _index = null;
264
75
  let _indexInitialized = false;
265
76
  function getIndex() {
@@ -272,6 +83,12 @@ export function createHafez(config) {
272
83
  }
273
84
  return _index;
274
85
  }
86
+ // The one null-index guard: a read-only instance may have no on-disk index
87
+ // yet — reads then return their empty shape instead of crashing.
88
+ function withIndex(fn, empty) {
89
+ const idx = getIndex();
90
+ return idx ? fn(idx) : empty;
91
+ }
275
92
  async function read(slug, depth = 'summary') {
276
93
  const filePath = findFile(slug);
277
94
  if (!filePath)
@@ -284,7 +101,7 @@ export function createHafez(config) {
284
101
  if (depth === 'frontmatter')
285
102
  return { kind, frontmatter, body: '' };
286
103
  if (depth === 'summary') {
287
- return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
104
+ return { kind, frontmatter, body: documentForFile(parsed).bodyBeforeSessionLog(body) };
288
105
  }
289
106
  return { kind, frontmatter, body };
290
107
  }
@@ -313,301 +130,142 @@ export function createHafez(config) {
313
130
  getIndex().upsertFromFile(u.slug, u.kind);
314
131
  }
315
132
  }
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;
133
+ async function runMutations(operations, single) {
134
+ if (readOnly)
135
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
136
+ // Declared aliases (insight → synthesis) become canonical before anything
137
+ // else reads the payload: validate and apply must never see different
138
+ // field names, or an alias validates ok and then applies as nothing.
139
+ operations = normalizeOperations(operations);
140
+ return withLock(async () => {
141
+ // Validate the whole payload before touching anything — all errors
142
+ // surface at once, localised to their op. Single ops surface their
143
+ // primary error class (NOT_FOUND / SLUG_EXISTS) as the thrown code.
144
+ const validation = validateBatchCore(operations);
145
+ if (!validation.valid) {
146
+ if (single) {
147
+ const opv = validation.operations[0];
148
+ throw new HafezError(opv.primaryCode ?? 'VALIDATION_FAILED', opv.errors.length === 1 ? opv.errors[0] : opv.errors.join('; '), opv.errors.length > 1 ? opv.errors : undefined);
344
149
  }
150
+ const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
151
+ throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(invalidCount, operations.length), formatOpErrors(validation));
345
152
  }
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);
412
- if (exists(slug))
413
- throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
414
- if (op.kind === 'entity') {
415
- const ef = op.fields;
416
- const fm = buildEntityFrontmatter(op.name, ef);
417
- let body = buildEntityBody(ef);
418
- const filePath = entityPath(slug);
419
- captureOriginal(filePath);
420
- mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
421
- body = syncRelatedSection(body, fm);
422
- writeFileSync(filePath, serializeFile(fm, body));
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
- };
153
+ // Apply receives the shape's output, not the raw payload, so a shape
154
+ // transform lands in the file instead of existing only in the
155
+ // validator's view.
156
+ operations = validation.parsedOps;
157
+ // Capture originals for rollback
158
+ const originals = new Map();
159
+ function captureOriginal(filePath) {
160
+ if (!originals.has(filePath)) {
161
+ originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
162
+ }
431
163
  }
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);
451
- if (exists(slug))
452
- throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
453
- const fm = {
454
- name: op.name,
455
- type: 'capture',
456
- status: 'active',
457
- created: today(),
458
- 'last-touched': today(),
459
- };
460
- const filePath = entityPath(slug);
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;
164
+ const applied = [];
165
+ const results = [];
166
+ let currentOp = -1;
167
+ try {
168
+ for (const op of operations) {
169
+ currentOp++;
170
+ // A warned op is the validate phase's skip disposition (today: a
171
+ // duplicate session — green-lit so the batch runs on, but nothing
172
+ // to write). The Spine synthesizes the result rather than calling
173
+ // Apply, so the rule lives only in the Op Spec. `code` rides along
174
+ // for the single-op unwrap: that caller has nothing to return and
175
+ // must throw under the target-exists class, not a generic one.
176
+ const warning = validation.operations[currentOp].warning;
177
+ if (warning) {
178
+ results.push({
179
+ op: op.op, slug: validation.operations[currentOp].slug,
180
+ status: 'skipped', warning, code: 'SLUG_EXISTS',
181
+ });
182
+ continue;
183
+ }
184
+ const a = applyOperation(op, { vaultPath, captureOriginal });
185
+ results.push(a.result);
186
+ if (a.result.status === 'ok')
187
+ applied.push(a);
188
+ }
485
189
  }
486
- else {
487
- const related = fm.related || [];
488
- if (!related.includes(op.target))
489
- related.push(op.target);
490
- fm.related = related;
190
+ catch (err) {
191
+ rollbackFiles(originals);
192
+ // The validate phase above catches semantic errors pre-execution;
193
+ // this localises residual execution-time failures (races, disk
194
+ // errors) to their op in batch mode.
195
+ if (!single && err instanceof HafezError && currentOp >= 0) {
196
+ const op = operations[currentOp];
197
+ const label = 'slug' in op ? op.slug : slugify(op.name);
198
+ throw new HafezError(err.code, opErrorLine(currentOp, op.op, label, err.message), err.details);
199
+ }
200
+ throw err;
491
201
  }
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) }],
202
+ // Batch reports per-op non-'ok' results; a single-op caller unwraps
203
+ // applied[0], so anything that did not apply must surface as a thrown
204
+ // error, not a TypeError on the unwrap — under the result's own error
205
+ // class where it has one.
206
+ if (single && applied.length === 0) {
207
+ const failed = results.find(r => r.status !== 'ok');
208
+ throw new HafezError(failed?.code ?? 'VALIDATION_FAILED', failed?.error || failed?.warning || 'operation was not applied');
209
+ }
210
+ const writtenAll = [...new Set(applied.flatMap(a => a.filesWritten))];
211
+ const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
212
+ const finalize = () => {
213
+ updateIndexPostCommit(applied);
214
+ // index.md regeneration is gated: session-only writes skip it
215
+ if ([...writtenAll, ...deletedAll].some(isIndexedRelPath)) {
216
+ regenerateIndexSafe();
217
+ }
218
+ // Session creates carry no index upserts — they are not indexed
219
+ // entities and never notified.
220
+ for (const a of applied) {
221
+ if (a.indexUpserts.length > 0)
222
+ onWrite?.(a.result.slug, single ? single.action : 'batch');
223
+ }
501
224
  };
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;
225
+ if (writtenAll.length + deletedAll.length > 0) {
226
+ const message = single ? applied[0].commitMessage : `batch: ${operations.length} operations`;
227
+ try {
228
+ await journal.commit(writtenAll, deletedAll, message);
229
+ }
230
+ catch (err) {
231
+ if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
232
+ // Commit-stage failure (e.g. a raced .git/index.lock): nothing was
233
+ // committed — restore the captured originals so files, journal,
234
+ // and index stay consistent. The adapter has already cleaned up
235
+ // its own partial state (staged files) before rethrowing.
236
+ rollbackFiles(originals);
237
+ throw err;
238
+ }
239
+ // Push-stage failure: the commit exists locally ("changes saved
240
+ // locally") — keep it and index it, then surface the error.
241
+ finalize();
242
+ throw err;
243
+ }
244
+ finalize();
518
245
  }
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
- };
246
+ return { applied, results };
247
+ });
551
248
  }
552
249
  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');
562
- regenerateIndexSafe();
563
- return a.result.slug;
564
- });
250
+ const op = kind === 'entity'
251
+ ? { op: 'create', kind, name, fields: fields }
252
+ : { op: 'create', kind, name, fields: fields };
253
+ const { applied } = await runMutations([op], { action: 'create' });
254
+ return applied[0].result.slug;
565
255
  }
566
256
  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;
576
- });
257
+ const { applied } = await runMutations([{ op: 'capture', name, notes }], { action: 'capture' });
258
+ return applied[0].result.slug;
577
259
  }
578
260
  async function update(slug, fields) {
579
- if (readOnly)
580
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
581
- return withLock(async () => {
582
- const a = applyOperation({ op: 'update', slug, fields });
583
- await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
584
- updateIndexPostCommit([a]);
585
- regenerateIndexSafe();
586
- onWrite?.(slug, 'update');
587
- return { matched_action: a.matchedAction };
588
- });
261
+ const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
262
+ return { matched_action: applied[0].matchedAction };
589
263
  }
590
264
  async function link(slug, target, relation) {
591
- if (readOnly)
592
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
593
- return withLock(async () => {
594
- const a = applyOperation({ op: 'link', slug, target, relation });
595
- await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
596
- updateIndexPostCommit([a]);
597
- regenerateIndexSafe();
598
- onWrite?.(slug, 'link');
599
- });
265
+ await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
600
266
  }
601
267
  async function unlink(slug, target, relation) {
602
- if (readOnly)
603
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
604
- return withLock(async () => {
605
- const a = applyOperation({ op: 'unlink', slug, target, relation });
606
- await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
607
- updateIndexPostCommit([a]);
608
- regenerateIndexSafe();
609
- onWrite?.(slug, 'unlink');
610
- });
268
+ await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
611
269
  }
612
270
  // "Recent" with day-granularity dates: primary sort is the frontmatter day,
613
271
  // ties break on git commit time (the real recency signal), then name for
@@ -656,8 +314,18 @@ export function createHafez(config) {
656
314
  if (!fp)
657
315
  return null;
658
316
  const kind = kindFromPath(fp);
659
- if (kind === 'knowledge')
660
- return { kind, type: 'knowledge' };
317
+ // The subtype decides which fields a knowledge note accepts (plan vs
318
+ // insight), so it is resolved here rather than re-read per op.
319
+ if (kind === 'knowledge') {
320
+ try {
321
+ const pf = parseFilePath(fp);
322
+ const subtype = pf.kind === 'knowledge' ? pf.frontmatter.subtype : undefined;
323
+ return { kind, type: 'knowledge', subtype };
324
+ }
325
+ catch {
326
+ return { kind, type: 'knowledge' };
327
+ }
328
+ }
661
329
  try {
662
330
  const pf = parseFilePath(fp);
663
331
  return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
@@ -676,118 +344,59 @@ export function createHafez(config) {
676
344
  buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
677
345
  };
678
346
  const report = [];
347
+ // The shape's OUTPUT, index-aligned with the report. Shapes may transform
348
+ // (action/evidence text is trimmed), and the semantic checks and Apply
349
+ // must both see what the shape emitted — validating the trimmed value but
350
+ // writing the raw one would store what validation never looked at.
351
+ const parsedOps = [];
679
352
  for (const [index, op] of operations.entries()) {
680
353
  // Shape and semantics in one pass: library callers get the same runtime
681
354
  // guarantee the CLI's JSON path has always had.
682
355
  const shape = BatchOperationSchema.safeParse(op);
683
356
  if (!shape.success) {
357
+ parsedOps.push(op);
684
358
  const raw = op;
685
359
  const label = raw && typeof raw.slug === 'string' ? raw.slug
686
360
  : raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
687
- report.push({
361
+ const entry = {
688
362
  index,
689
363
  op: typeof raw?.op === 'string' ? raw.op : '?',
690
364
  slug: label,
691
365
  errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
692
- });
366
+ };
367
+ // A missing slug outranks field-shape problems: spec.check never runs
368
+ // when the shape fails, but the direct paths always surfaced NOT_FOUND
369
+ // first (and scripts branch on its exit code) — keep that precedence.
370
+ if (raw && typeof raw.slug === 'string' && raw.slug &&
371
+ slugBearingOpNames().includes(entry.op) &&
372
+ !existsAnywhere(raw.slug)) {
373
+ entry.errors.unshift(notFoundError(raw.slug));
374
+ entry.primaryCode = 'NOT_FOUND';
375
+ }
376
+ report.push(entry);
693
377
  continue;
694
378
  }
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 };
379
+ const parsed = shape.data;
380
+ parsedOps.push(parsed);
381
+ const spec = specFor(parsed);
382
+ const res = spec.check(parsed, ctx);
383
+ const entry = { index, op: parsed.op, slug: res.slug, errors: res.errors };
698
384
  if (res.created)
699
385
  entry.created = true;
700
386
  if (res.warning)
701
387
  entry.warning = res.warning;
388
+ if (res.primaryCode)
389
+ entry.primaryCode = res.primaryCode;
702
390
  report.push(entry);
703
391
  }
704
- return { valid: report.every(o => o.errors.length === 0), operations: report };
392
+ return { valid: report.every(o => o.errors.length === 0), operations: report, parsedOps };
705
393
  }
706
394
  function formatOpErrors(report) {
707
- return report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
395
+ return report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
708
396
  }
709
397
  async function batch(operations) {
710
- if (readOnly)
711
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
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
- }
720
- // Capture originals for rollback
721
- const originals = new Map();
722
- function captureOriginal(filePath) {
723
- if (!originals.has(filePath)) {
724
- originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
725
- }
726
- }
727
- const appliedOps = [];
728
- const results = [];
729
- let currentOp = -1;
730
- try {
731
- for (const op of operations) {
732
- currentOp++;
733
- const a = applyOperation(op, captureOriginal);
734
- results.push(a.result);
735
- if (a.result.status === 'ok')
736
- appliedOps.push(a);
737
- }
738
- }
739
- catch (err) {
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);
748
- }
749
- throw err;
750
- }
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))];
754
- const indexAfterBatch = () => {
755
- updateIndexPostCommit(appliedOps);
756
- // Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
757
- const anyIndexedTouched = [...writtenAll, ...deletedAll].some(isIndexedRelPath);
758
- if (anyIndexedTouched) {
759
- regenerateIndexSafe();
760
- }
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
- }
767
- };
768
- if (writtenAll.length + deletedAll.length > 0) {
769
- try {
770
- await journal.commit(writtenAll, deletedAll, `batch: ${operations.length} operations`);
771
- }
772
- catch (err) {
773
- if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
774
- // Commit-stage failure (e.g. a raced .git/index.lock): nothing was
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);
780
- throw err;
781
- }
782
- // Push-stage failure: the batch commit exists locally ("changes
783
- // saved locally") — keep it and index it, then surface the error.
784
- indexAfterBatch();
785
- throw err;
786
- }
787
- indexAfterBatch();
788
- }
789
- return results;
790
- });
398
+ const { results } = await runMutations(operations, null);
399
+ return results;
791
400
  }
792
401
  async function validate() {
793
402
  const report = {
@@ -878,117 +487,14 @@ export function createHafez(config) {
878
487
  }
879
488
  return report;
880
489
  }
881
- function promoteCore(slug, target, filePath, fm, body, kind) {
882
- const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
883
- const contract = getContract(currentType);
884
- if (!contract.canPromoteTo.includes(target)) {
885
- throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
886
- }
887
- const modifiedFiles = [];
888
- const deletedFiles = [];
889
- if (target === 'knowledge') {
890
- // Cross-kind: move file from entities/ to knowledge/
891
- const newPath = knowledgePath(slug);
892
- const notesSection = findSection(body, 'Notes');
893
- const notesContent = notesSection?.content ?? '';
894
- const newFm = {
895
- name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
896
- };
897
- if (fm.description)
898
- newFm.description = fm.description;
899
- // resource is intentionally dropped on entity→knowledge promote (entity-only field)
900
- if (fm.domain)
901
- newFm.domain = fm.domain;
902
- if (fm.related)
903
- newFm.related = fm.related;
904
- if (fm.tags)
905
- newFm.tags = fm.tags;
906
- const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
907
- const finalBody = syncRelatedSection(knBody, newFm);
908
- writeFileSync(newPath, serializeFile(newFm, finalBody));
909
- unlinkSync(filePath);
910
- modifiedFiles.push(relative(vaultPath, newPath));
911
- // The deletion must be staged too — `git add` of a removed path stages
912
- // the removal; omitting it leaves the vault permanently dirty.
913
- deletedFiles.push(relative(vaultPath, filePath));
914
- }
915
- else {
916
- // Same-kind: capture→entity, capture→project, entity→project
917
- fm.type = target;
918
- fm['last-touched'] = today();
919
- let newBody = body;
920
- if (target === 'entity') {
921
- newBody = newBody.replace(/^## Notes$/m, '## Context');
922
- }
923
- else {
924
- // project
925
- newBody = newBody.replace(/^## Notes$/m, '## Purpose');
926
- newBody = newBody.replace(/^## Context$/m, '## Purpose');
927
- if (!newBody.includes('## Goals')) {
928
- const sections = newBody.split(/\n(?=## )/m);
929
- const purposeIdx = sections.findIndex(s => s.startsWith('## Purpose'));
930
- if (purposeIdx !== -1) {
931
- sections.splice(purposeIdx + 1, 0, '## Goals\n');
932
- }
933
- else {
934
- sections.push('## Goals\n');
935
- }
936
- newBody = sections.join('\n');
937
- }
938
- }
939
- if (!newBody.includes('## Session Log'))
940
- newBody += '\n## Session Log\n';
941
- // Add promotion session log entry
942
- const logEntry = `### ${today()} — system [decision]\nSummary: Promoted from ${currentType} to ${target}\n`;
943
- const logIdx = newBody.lastIndexOf('## Session Log');
944
- if (logIdx !== -1) {
945
- const insertAt = logIdx + '## Session Log'.length + 1;
946
- newBody = newBody.slice(0, insertAt) + '\n' + logEntry + newBody.slice(insertAt);
947
- }
948
- newBody = syncRelatedSection(newBody, fm);
949
- writeFileSync(filePath, serializeFile(fm, newBody));
950
- modifiedFiles.push(relative(vaultPath, filePath));
951
- }
952
- return { modifiedFiles, deletedFiles };
953
- }
954
490
  async function promote(slug, target) {
955
- if (readOnly)
956
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
957
- return withLock(async () => {
958
- const filePath = findFile(slug);
959
- if (!filePath)
960
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
961
- // Capture original content for rollback
962
- const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
963
- const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
964
- let a;
965
- try {
966
- a = applyOperation({ op: 'promote', slug, target });
967
- await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
968
- }
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.
979
- if (target === 'knowledge' && originalContent) {
980
- writeFileSync(filePath, originalContent);
981
- // Never unlink the file we just restored (SEC-4)
982
- if (knowledgeTarget && knowledgeTarget !== filePath && existsSync(knowledgeTarget))
983
- unlinkSync(knowledgeTarget);
984
- }
985
- throw err;
986
- }
987
- updateIndexPostCommit([a]);
988
- regenerateIndexSafe();
989
- onWrite?.(slug, 'promote');
990
- return slug;
991
- });
491
+ // Rollback safety rides the Spine: applyOperation's promote arm captures
492
+ // both the source file and (for cross-kind moves) the knowledge target
493
+ // before writing, so a commit-stage failure restores the source and
494
+ // removes the created file (SEC-4: never unlink a restored path —
495
+ // rollbackFiles only unlinks paths captured as previously-absent).
496
+ await runMutations([{ op: 'promote', slug, target }], { action: 'promote' });
497
+ return slug;
992
498
  }
993
499
  const emptyStats = {
994
500
  counts: { active: 0, paused: 0, done: 0 },
@@ -1000,59 +506,38 @@ export function createHafez(config) {
1000
506
  read,
1001
507
  update,
1002
508
  promote,
1003
- query: async (opts) => {
1004
- const idx = getIndex();
1005
- if (!idx)
1006
- return { items: [], total: 0 };
1007
- return queryEntities(idx, opts);
1008
- },
1009
- queryUnified: async (opts) => {
1010
- const idx = getIndex();
1011
- if (!idx)
1012
- return { items: [], total: 0 };
1013
- return queryUnified(idx, opts);
1014
- },
509
+ query: async (opts) => withIndex(idx => idx.queryEntities(opts), { items: [], total: 0 }),
510
+ queryUnified: async (opts) => withIndex(idx => idx.queryUnified(opts), { items: [], total: 0 }),
1015
511
  create,
1016
512
  capture,
1017
513
  link,
1018
514
  unlink,
1019
515
  batch,
1020
- validateBatch: async (operations) => validateBatchCore(operations),
516
+ // Same normalization as the apply path — dry-run must judge the payload
517
+ // apply would actually run.
518
+ validateBatch: async (operations) => {
519
+ // parsedOps is the Spine's internal handoff, not part of the report.
520
+ const { valid, operations: report } = validateBatchCore(normalizeOperations(operations));
521
+ return { valid, operations: report };
522
+ },
1021
523
  sync: async () => {
1022
524
  if (readOnly)
1023
525
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1024
526
  return withLock(() => journal.sync());
1025
527
  },
1026
- children: async (slug) => {
1027
- const idx = getIndex();
1028
- if (!idx)
1029
- return { items: [], total: 0 };
1030
- return queryChildren(idx, slug);
1031
- },
1032
- related_to: async (slug, relation) => {
1033
- const idx = getIndex();
1034
- if (!idx)
1035
- return { items: [], total: 0 };
1036
- return queryRelatedTo(idx, slug, relation);
1037
- },
1038
- query_knowledge: async (opts) => {
1039
- const idx = getIndex();
1040
- if (!idx)
1041
- return { items: [], total: 0 };
1042
- return queryKnowledge(idx, opts);
1043
- },
528
+ children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
529
+ related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
530
+ query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
1044
531
  validate,
1045
- search: async (query, kind) => {
1046
- const idx = getIndex();
1047
- if (!idx)
1048
- return [];
1049
- idx.syncIfStale();
1050
- return idx.search(query, kind);
1051
- },
532
+ search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
1052
533
  rebuildIndex: async () => {
1053
534
  if (readOnly)
1054
535
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1055
- getIndex().rebuild();
536
+ // A rebuild reads every vault file and rewrites the whole index, so it
537
+ // takes the same two-level write lock every mutation takes — otherwise it
538
+ // can interleave with a mutation's file writes and index a half-written
539
+ // vault.
540
+ await withLock(async () => { getIndex().rebuild(); });
1056
541
  },
1057
542
  stats: async () => {
1058
543
  const idx = getIndex();
@@ -1068,5 +553,5 @@ export function createHafez(config) {
1068
553
  };
1069
554
  }
1070
555
  export { HafezError } from './types.js';
1071
- export { getBrief, getNextActions, findSection } from './document.js';
556
+ export { getBrief, getNextActions } from './document.js';
1072
557
  export { parseSessionLogHeading, parseSessionLog } from './document.js';