hafez 1.2.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.
Files changed (52) hide show
  1. package/README.md +67 -9
  2. package/dist/apply.d.ts +30 -0
  3. package/dist/apply.js +582 -0
  4. package/dist/batch-ops.d.ts +470 -0
  5. package/dist/batch-ops.js +446 -0
  6. package/dist/cli/args.d.ts +19 -0
  7. package/dist/cli/args.js +53 -0
  8. package/dist/cli/batch-errors.js +6 -3
  9. package/dist/cli/commands.d.ts +24 -108
  10. package/dist/cli/commands.js +297 -313
  11. package/dist/cli/help-agent.d.ts +0 -5
  12. package/dist/cli/help-agent.js +44 -5
  13. package/dist/cli/index.d.ts +41 -0
  14. package/dist/cli/index.js +172 -172
  15. package/dist/cli/resolve-vault.js +2 -1
  16. package/dist/cli/schema-introspect.d.ts +1 -1
  17. package/dist/cli/schema-introspect.js +6 -22
  18. package/dist/cli/upgrade.d.ts +42 -0
  19. package/dist/cli/upgrade.js +305 -0
  20. package/dist/cli.js +0 -0
  21. package/dist/contracts.d.ts +55 -6
  22. package/dist/contracts.js +110 -23
  23. package/dist/dates.d.ts +10 -0
  24. package/dist/dates.js +27 -0
  25. package/dist/db.d.ts +27 -2
  26. package/dist/db.js +318 -56
  27. package/dist/digest.js +2 -0
  28. package/dist/document.d.ts +117 -0
  29. package/dist/document.js +578 -0
  30. package/dist/export-okf.js +48 -34
  31. package/dist/git.d.ts +7 -8
  32. package/dist/git.js +198 -63
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +302 -915
  35. package/dist/knowledge-index.d.ts +23 -1
  36. package/dist/knowledge-index.js +71 -13
  37. package/dist/merge.d.ts +1 -1
  38. package/dist/merge.js +106 -86
  39. package/dist/schema.js +4 -4
  40. package/dist/templates.d.ts +6 -0
  41. package/dist/templates.js +27 -27
  42. package/dist/types.d.ts +86 -12
  43. package/dist/vault.d.ts +51 -6
  44. package/dist/vault.js +114 -11
  45. package/package.json +2 -2
  46. package/skill/SKILL.md +51 -110
  47. package/dist/parser.d.ts +0 -18
  48. package/dist/parser.js +0 -68
  49. package/dist/query.d.ts +0 -24
  50. package/dist/query.js +0 -167
  51. package/dist/sections.d.ts +0 -54
  52. package/dist/sections.js +0 -297
package/dist/index.js CHANGED
@@ -1,20 +1,21 @@
1
1
  // src/index.ts
2
- import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath } from './vault.js';
3
- import { gitCommitAndPush, gitSync, gitChangelog, gitUnstage, gitFileTimes } from './git.js';
2
+ import { parseFilePath, slugify, kindFromPath, kindDir, scanVaultDir, isIndexedRelPath, findVaultFile, vaultFileExists } from './vault.js';
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 } from './parser.js';
7
- import { setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findNextStructuralHeading, findSection } from './sections.js';
8
- import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
9
- import { getContract } from './contracts.js';
5
+ import { documentForFile } from './document.js';
10
6
  import { createIndex } from './db.js';
11
7
  import { generateVaultIndex } from './knowledge-index.js';
12
8
  import { HafezError } from './types.js';
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, unlinkSync } from 'fs';
14
- import { join, relative, basename } 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;
16
+ // The persistence seam (CONTEXT.md: Journal). Everything below talks to
17
+ // history through this port; git is just the default adapter.
18
+ const journal = config.persistence ?? createGitJournal(vaultPath, gitConfig);
18
19
  // Two-level write lock: an in-process promise chain serializes calls within
19
20
  // this instance, then a cross-process advisory lock (proper-lockfile, mkdir
20
21
  // based — works on network/drvfs mounts) serializes against other hafez
@@ -48,244 +49,28 @@ export function createHafez(config) {
48
49
  catch { /* stale lock reclaimed by another process */ }
49
50
  }
50
51
  }
51
- function entityPath(slug) { return resolveFilePath(vaultPath, slug, 'entity'); }
52
- function knowledgePath(slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
53
- function findFile(slug) {
54
- const ep = entityPath(slug);
55
- if (existsSync(ep))
56
- return ep;
57
- const kp = knowledgePath(slug);
58
- if (existsSync(kp))
59
- return kp;
60
- return null;
61
- }
62
- function exists(slug) {
63
- // An invalid slug definitionally doesn't exist — reference checks (create
64
- // parent/related, link targets, validate) must report "does not exist",
65
- // not crash. validate() in particular exists to REPORT malformed refs.
66
- try {
67
- return findFile(slug) !== null;
68
- }
69
- catch {
70
- return false;
71
- }
72
- }
73
- function today() { return new Date().toISOString().slice(0, 10); }
74
- const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
75
- const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
76
- function validateKindFields(kind, fields) {
77
- if (kind === 'knowledge') {
78
- const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined);
79
- if (invalid.length > 0) {
80
- throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
81
- }
82
- }
83
- if (kind === 'entity') {
84
- const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
85
- if (invalid.length > 0) {
86
- throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
87
- }
88
- }
89
- }
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); }
90
55
  function regenerateIndexSafe() {
91
56
  // Runs post-commit: the vault write is already persisted/pushed, so an
92
57
  // index.md failure must warn, never reject the operation.
93
58
  try {
94
- 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());
95
69
  }
96
70
  catch (err) {
97
71
  process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
98
72
  }
99
73
  }
100
- // Shared by create(), batch(), and the batch validate phase so optional
101
- // fields can't silently diverge between paths (see CLAUDE.md Validation
102
- // Gotchas). `existsFn` is pluggable so validateBatchCore can supply a
103
- // simulation-aware check (same-batch creates count as existing).
104
- function buildEntityFrontmatter(name, ef, existsFn = exists) {
105
- const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
106
- if (ef.domain?.length)
107
- fm.domain = ef.domain;
108
- if (ef.parent) {
109
- if (!existsFn(ef.parent))
110
- throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
111
- fm.parent = ef.parent;
112
- }
113
- if (ef.related?.length) {
114
- for (const r of ef.related) {
115
- if (!existsFn(r))
116
- throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
117
- }
118
- fm.related = ef.related;
119
- }
120
- if (ef.tags?.length)
121
- fm.tags = ef.tags;
122
- if (ef.description)
123
- fm.description = ef.description;
124
- if (ef.resource)
125
- fm.resource = ef.resource;
126
- const errors = validateEntityFrontmatter(fm);
127
- if (errors.length > 0)
128
- throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
129
- return fm;
130
- }
131
- // Shared by create() and batch() like buildEntityFrontmatter — body-side
132
- // optional fields (brief, actions) must behave identically in both paths.
133
- function buildEntityBody(ef) {
134
- let body = bodyTemplate(ef.type, { purpose: ef.purpose });
135
- if (ef.brief)
136
- body = setBrief(body, ef.brief);
137
- if (ef.add_action)
138
- body = addNextAction(body, ef.add_action);
139
- for (const action of ef.add_actions ?? [])
140
- body = addNextAction(body, action);
141
- return body;
142
- }
143
- function buildKnowledgeFrontmatter(name, kf, existsFn = exists) {
144
- const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
145
- if (kf.domain?.length)
146
- fm.domain = kf.domain;
147
- if (kf.related?.length) {
148
- for (const r of kf.related) {
149
- if (!existsFn(r))
150
- throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
151
- }
152
- fm.related = kf.related;
153
- }
154
- if (kf.tags?.length)
155
- fm.tags = kf.tags;
156
- if (kf.description)
157
- fm.description = kf.description;
158
- const errors = validateKnowledgeFrontmatter(fm);
159
- if (errors.length > 0)
160
- throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
161
- if (kf.subtype)
162
- fm.subtype = kf.subtype;
163
- if (kf['session-date'])
164
- fm['session-date'] = kf['session-date'];
165
- return fm;
166
- }
167
- function replaceSection(body, heading, content, insertBefore) {
168
- const idx = body.indexOf(heading);
169
- if (idx >= 0) {
170
- const afterPos = idx + heading.length;
171
- const nextH = findNextStructuralHeading(body, afterPos);
172
- if (nextH !== -1) {
173
- return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
174
- }
175
- return body.slice(0, afterPos) + '\n\n' + content + '\n';
176
- }
177
- if (insertBefore) {
178
- const beforeIdx = body.indexOf(insertBefore);
179
- if (beforeIdx >= 0) {
180
- return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
181
- }
182
- }
183
- return body + '\n\n' + heading + '\n\n' + content + '\n';
184
- }
185
- function insertSessionLog(body, entry) {
186
- const slHeader = '## Session Log';
187
- const slIdx = body.indexOf(slHeader);
188
- if (slIdx >= 0) {
189
- const insertPos = slIdx + slHeader.length;
190
- return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
191
- }
192
- return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
193
- }
194
- function appendToSection(body, heading, content) {
195
- const idx = body.indexOf(heading);
196
- if (idx >= 0) {
197
- const afterPos = idx + heading.length;
198
- const nextH = findNextStructuralHeading(body, afterPos);
199
- if (nextH !== -1) {
200
- return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
201
- }
202
- return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
203
- }
204
- return body + '\n\n' + heading + '\n\n' + content + '\n';
205
- }
206
- function applyUpdateFields(fm, body, fields) {
207
- if (fields.status !== undefined)
208
- fm.status = fields.status;
209
- if (fields.current_state !== undefined) {
210
- body = replaceSection(body, '## Current State', fields.current_state, '## Session Log');
211
- }
212
- if (fields.brief !== undefined) {
213
- body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
214
- }
215
- let matchedAction;
216
- if (fields.add_action)
217
- body = addNextAction(body, fields.add_action);
218
- if (fields.add_actions) {
219
- for (const action of fields.add_actions) {
220
- body = addNextAction(body, action);
221
- }
222
- }
223
- if (fields.complete_action) {
224
- const r = completeNextAction(body, fields.complete_action);
225
- body = r.body;
226
- matchedAction = r.matched;
227
- }
228
- if (fields.remove_action) {
229
- const r = removeNextAction(body, fields.remove_action);
230
- body = r.body;
231
- matchedAction = r.matched;
232
- }
233
- if (fields.clear_actions)
234
- body = clearNextActions(body);
235
- if (fields.session_log) {
236
- const logErrors = validateSessionLogEntry(fields.session_log);
237
- if (logErrors.length > 0)
238
- throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
239
- body = insertSessionLog(body, formatSessionLogEntry(fields.session_log));
240
- }
241
- // Domain (shared) + knowledge-only metadata
242
- if (fields.domain !== undefined) {
243
- if (fields.domain.length === 0)
244
- delete fm.domain;
245
- else
246
- fm.domain = fields.domain;
247
- }
248
- if (fields.description !== undefined) {
249
- if (fields.description === '' || fields.description === null)
250
- delete fm.description;
251
- else
252
- fm.description = fields.description;
253
- }
254
- if (fields.resource !== undefined) {
255
- if (fields.resource === '' || fields.resource === null)
256
- delete fm.resource;
257
- else
258
- fm.resource = fields.resource;
259
- }
260
- if (fields.confidence !== undefined)
261
- fm.confidence = fields.confidence;
262
- if (fields.tags !== undefined)
263
- fm.tags = fields.tags;
264
- if (fields.related !== undefined) {
265
- for (const r of fields.related) {
266
- if (!exists(r))
267
- throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
268
- }
269
- fm.related = fields.related;
270
- }
271
- if (fields.synthesis !== undefined) {
272
- body = replaceSection(body, '## Synthesis', fields.synthesis);
273
- }
274
- if (fields.add_evidence) {
275
- const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
276
- body = appendToSection(body, '## Evidence', sanitized);
277
- fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
278
- fm['last-reinforced'] = today();
279
- // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
280
- // removed. Confidence changes are now a deliberate decision via update --confidence.
281
- }
282
- if (fields.add_source) {
283
- body = appendToSection(body, '## Sources', fields.add_source);
284
- }
285
- if (fm['last-touched'] !== today())
286
- fm['last-touched'] = today();
287
- return { fm, body, matchedAction };
288
- }
289
74
  let _index = null;
290
75
  let _indexInitialized = false;
291
76
  function getIndex() {
@@ -298,171 +83,190 @@ export function createHafez(config) {
298
83
  }
299
84
  return _index;
300
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
+ }
301
92
  async function read(slug, depth = 'summary') {
302
93
  const filePath = findFile(slug);
303
94
  if (!filePath)
304
95
  throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
305
96
  const parsed = parseFilePath(filePath);
97
+ // findFile only resolves the indexed kinds, so the session arm is unreachable
98
+ const kind = parsed.kind === 'session' ? 'knowledge' : parsed.kind;
306
99
  const frontmatter = parsed.frontmatter;
307
100
  const body = parsed.body;
308
101
  if (depth === 'frontmatter')
309
- return { frontmatter, body: '' };
102
+ return { kind, frontmatter, body: '' };
310
103
  if (depth === 'summary') {
311
- const sessionLogIdx = body.indexOf('## Session Log');
312
- return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
104
+ return { kind, frontmatter, body: documentForFile(parsed).bodyBeforeSessionLog(body) };
313
105
  }
314
- return { frontmatter, body };
106
+ return { kind, frontmatter, body };
315
107
  }
316
- async function create(kind, name, fields) {
317
- if (readOnly)
318
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
319
- return withLock(async () => {
320
- const slug = slugify(name);
321
- if (exists(slug))
322
- throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
323
- if (kind === 'entity') {
324
- const ef = fields;
325
- const fm = buildEntityFrontmatter(name, ef);
326
- let body = buildEntityBody(ef);
327
- const filePath = entityPath(slug);
328
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
329
- body = syncRelatedSection(body, fm);
330
- writeFileSync(filePath, serializeFile(fm, body));
331
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
332
- getIndex().upsertFromFile(slug, 'entity');
333
- onWrite?.(slug, 'create');
108
+ function rollbackFiles(originals) {
109
+ for (const [path, content] of originals) {
110
+ if (content === null) {
111
+ try {
112
+ unlinkSync(path);
113
+ }
114
+ catch { }
334
115
  }
335
116
  else {
336
- const kf = (fields || {});
337
- const fm = buildKnowledgeFrontmatter(name, kf);
338
- let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
339
- const filePath = knowledgePath(slug);
340
- mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
341
- body = syncRelatedSection(body, fm);
342
- writeFileSync(filePath, serializeFile(fm, body));
343
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
344
- getIndex().upsertFromFile(slug, 'knowledge');
345
- onWrite?.(slug, 'create');
117
+ writeFileSync(path, content);
346
118
  }
347
- regenerateIndexSafe();
348
- return slug;
349
- });
119
+ }
350
120
  }
351
- async function capture(name, notes) {
352
- if (readOnly)
353
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
354
- return withLock(async () => {
355
- const slug = slugify(name);
356
- if (exists(slug))
357
- throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
358
- const fm = {
359
- name,
360
- type: 'capture',
361
- status: 'active',
362
- created: today(),
363
- 'last-touched': today(),
364
- };
365
- const body = bodyTemplate('capture', { notes });
366
- const filePath = entityPath(slug);
367
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
368
- writeFileSync(filePath, serializeFile(fm, body));
369
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `capture: ${name}`, gitConfig);
370
- getIndex().upsertFromFile(slug, 'entity');
371
- regenerateIndexSafe();
372
- onWrite?.(slug, 'capture');
373
- return slug;
374
- });
121
+ // All SQLite writes happen here, strictly after the git commit succeeded.
122
+ // A failed commit rolls back files only — the index was never touched, so
123
+ // files, git, and index stay consistent (pre-collapse, promote removed its
124
+ // index row mid-apply and a rollback lost it).
125
+ function updateIndexPostCommit(applied) {
126
+ for (const a of applied) {
127
+ for (const slug of a.indexRemovals)
128
+ getIndex().removeItem(slug);
129
+ for (const u of a.indexUpserts)
130
+ getIndex().upsertFromFile(u.slug, u.kind);
131
+ }
375
132
  }
376
- async function update(slug, fields) {
133
+ async function runMutations(operations, single) {
377
134
  if (readOnly)
378
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);
379
140
  return withLock(async () => {
380
- const filePath = findFile(slug);
381
- if (!filePath)
382
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
383
- const kind = kindFromPath(filePath);
384
- validateKindFields(kind, fields);
385
- let { frontmatter: fm, body } = parseFilePath(filePath);
386
- const applied = applyUpdateFields(fm, body, fields);
387
- fm = applied.fm;
388
- body = applied.body;
389
- // Session log archival
390
- if (countSessionLogEntries(body) >= 10) {
391
- const extracted = extractOldestSessionLogEntry(body);
392
- if (extracted) {
393
- const archiveDir = join(vaultPath, 'entities', 'archive');
394
- mkdirSync(archiveDir, { recursive: true });
395
- const archivePath = join(archiveDir, `${slug}-log.md`);
396
- appendFileSync(archivePath, extracted.entry + '\n\n');
397
- body = extracted.remaining;
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);
398
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));
399
152
  }
400
- body = syncRelatedSection(body, fm);
401
- writeFileSync(filePath, serializeFile(fm, body));
402
- const files = [relative(vaultPath, filePath)];
403
- const archivePath = join(vaultPath, 'entities', 'archive', `${slug}-log.md`);
404
- if (existsSync(archivePath))
405
- files.push(relative(vaultPath, archivePath));
406
- await gitCommitAndPush(vaultPath, files, `update: ${slug}`, gitConfig);
407
- getIndex().upsertFromFile(slug, kind);
408
- regenerateIndexSafe();
409
- onWrite?.(slug, 'update');
410
- return { matched_action: applied.matchedAction };
411
- });
412
- }
413
- async function link(slug, target, relation) {
414
- if (readOnly)
415
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
416
- return withLock(async () => {
417
- const filePath = findFile(slug);
418
- if (!filePath)
419
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
420
- if (!exists(target))
421
- throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
422
- let { frontmatter: fm, body } = parseFilePath(filePath);
423
- if (relation === 'parent') {
424
- fm.parent = target;
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
+ }
425
163
  }
426
- else {
427
- const related = fm.related || [];
428
- if (!related.includes(target))
429
- related.push(target);
430
- fm.related = related;
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
+ }
431
189
  }
432
- body = syncRelatedSection(body, fm);
433
- writeFileSync(filePath, serializeFile(fm, body));
434
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `link: ${slug} → ${target} (${relation})`, gitConfig);
435
- const kind = kindFromPath(filePath);
436
- getIndex().upsertFromFile(slug, kind);
437
- regenerateIndexSafe();
438
- onWrite?.(slug, 'link');
439
- });
440
- }
441
- async function unlink(slug, target, relation) {
442
- if (readOnly)
443
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
444
- return withLock(async () => {
445
- const filePath = findFile(slug);
446
- if (!filePath)
447
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
448
- let { frontmatter: fm, body } = parseFilePath(filePath);
449
- if (relation === 'parent' && fm.parent === target) {
450
- delete fm.parent;
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;
451
201
  }
452
- else if (relation === 'related' && fm.related) {
453
- fm.related = fm.related.filter((r) => r !== target);
454
- if (fm.related.length === 0)
455
- delete fm.related;
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
+ }
224
+ };
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();
456
245
  }
457
- body = syncRelatedSection(body, fm);
458
- writeFileSync(filePath, serializeFile(fm, body));
459
- await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
460
- const kind = kindFromPath(filePath);
461
- getIndex().upsertFromFile(slug, kind);
462
- regenerateIndexSafe();
463
- onWrite?.(slug, 'unlink');
246
+ return { applied, results };
464
247
  });
465
248
  }
249
+ async function create(kind, name, fields) {
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;
255
+ }
256
+ async function capture(name, notes) {
257
+ const { applied } = await runMutations([{ op: 'capture', name, notes }], { action: 'capture' });
258
+ return applied[0].result.slug;
259
+ }
260
+ async function update(slug, fields) {
261
+ const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
262
+ return { matched_action: applied[0].matchedAction };
263
+ }
264
+ async function link(slug, target, relation) {
265
+ await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
266
+ }
267
+ async function unlink(slug, target, relation) {
268
+ await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
269
+ }
466
270
  // "Recent" with day-granularity dates: primary sort is the frontmatter day,
467
271
  // ties break on git commit time (the real recency signal), then name for
468
272
  // files with no commit history yet. Without this the top-5 lists leak
@@ -478,8 +282,8 @@ export function createHafez(config) {
478
282
  const hasTies = new Set(candidates.map(day)).size < candidates.length;
479
283
  if (!hasTies)
480
284
  return rows.slice(0, 5);
481
- const times = await gitFileTimes(vaultPath, mode);
482
- const gitTime = (r) => times.get(`entities/${r.slug}.md`) ?? '';
285
+ const times = await journal.fileTimes(mode);
286
+ const gitTime = (r) => times.get(`${kindDir('entity')}/${r.slug}.md`) ?? '';
483
287
  candidates.sort((a, b) => {
484
288
  const dayCmp = day(b).localeCompare(day(a));
485
289
  if (dayCmp !== 0)
@@ -499,399 +303,100 @@ export function createHafez(config) {
499
303
  // slug → what it would be after earlier ops in this batch
500
304
  const simulated = new Map();
501
305
  const simulatedSessions = new Set();
502
- const resolve = (slug) => {
503
- const sim = simulated.get(slug);
504
- if (sim)
505
- return sim;
506
- const fp = findFile(slug);
507
- if (!fp)
508
- return null;
509
- const kind = kindFromPath(fp);
510
- if (kind === 'knowledge')
511
- return { kind, type: 'knowledge' };
512
- try {
513
- return { kind, type: parseFilePath(fp).frontmatter.type };
514
- }
515
- catch {
516
- return { kind, type: 'entity' };
517
- }
518
- };
519
306
  const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
520
- const report = [];
521
- for (const [index, op] of operations.entries()) {
522
- const errors = [];
523
- let warning;
524
- let slug;
525
- let created = false;
526
- const pushErr = (err) => {
527
- const e = err;
528
- errors.push(e.details?.length ? `${e.message}: ${e.details.join('; ')}` : e.message);
529
- };
530
- if (op.op === 'update') {
531
- slug = op.slug;
532
- const target = resolve(op.slug);
533
- if (!target)
534
- errors.push(`'${op.slug}' not found`);
535
- else {
307
+ // Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
308
+ const ctx = {
309
+ resolve: (slug) => {
310
+ const sim = simulated.get(slug);
311
+ if (sim)
312
+ return sim;
313
+ const fp = findFile(slug);
314
+ if (!fp)
315
+ return null;
316
+ const kind = kindFromPath(fp);
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') {
536
320
  try {
537
- validateKindFields(target.kind, op.fields);
321
+ const pf = parseFilePath(fp);
322
+ const subtype = pf.kind === 'knowledge' ? pf.frontmatter.subtype : undefined;
323
+ return { kind, type: 'knowledge', subtype };
538
324
  }
539
- catch (err) {
540
- pushErr(err);
541
- }
542
- }
543
- if (op.fields.session_log) {
544
- const logErrors = validateSessionLogEntry(op.fields.session_log);
545
- if (logErrors.length > 0)
546
- errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
547
- }
548
- if (op.fields.related) {
549
- for (const r of op.fields.related) {
550
- if (!existsAnywhere(r))
551
- errors.push(`Related slug '${r}' does not exist`);
325
+ catch {
326
+ return { kind, type: 'knowledge' };
552
327
  }
553
328
  }
554
- }
555
- else if (op.op === 'create' && op.kind === 'session') {
556
- slug = slugify(op.name);
557
- if (exists(slug) || existsSync(join(vaultPath, 'sessions', `${slug}.md`)) || simulatedSessions.has(slug)) {
558
- warning = `session '${slug}' already exists — op will be skipped (batch continues)`;
559
- }
560
- else {
561
- simulatedSessions.add(slug);
562
- created = true;
563
- }
564
- }
565
- else if (op.op === 'create') {
566
- slug = slugify(op.name);
567
- if (existsAnywhere(slug))
568
- errors.push(`Slug '${slug}' already exists`);
569
- if (op.kind === 'entity') {
570
- try {
571
- buildEntityFrontmatter(op.name, op.fields, existsAnywhere);
572
- }
573
- catch (err) {
574
- pushErr(err);
575
- }
576
- simulated.set(slug, { kind: 'entity', type: op.fields.type });
329
+ try {
330
+ const pf = parseFilePath(fp);
331
+ return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
577
332
  }
578
- else {
579
- try {
580
- buildKnowledgeFrontmatter(op.name, (op.fields || {}), existsAnywhere);
581
- }
582
- catch (err) {
583
- pushErr(err);
584
- }
585
- simulated.set(slug, { kind: 'knowledge', type: 'knowledge' });
333
+ catch {
334
+ return { kind, type: 'entity' };
586
335
  }
587
- created = errors.length === 0;
588
- }
589
- else if (op.op === 'capture') {
590
- slug = slugify(op.name);
591
- if (existsAnywhere(slug))
592
- errors.push(`Slug '${slug}' already exists`);
593
- simulated.set(slug, { kind: 'entity', type: 'capture' });
594
- created = errors.length === 0;
595
- }
596
- else if (op.op === 'link') {
597
- slug = op.slug;
598
- if (!resolve(op.slug))
599
- errors.push(`'${op.slug}' not found`);
600
- if (!existsAnywhere(op.target))
601
- errors.push(`Target '${op.target}' does not exist`);
602
- }
603
- else if (op.op === 'unlink') {
604
- slug = op.slug;
605
- if (!resolve(op.slug))
606
- errors.push(`'${op.slug}' not found`);
607
- }
608
- else {
609
- // promote
610
- slug = op.slug;
611
- const cur = resolve(op.slug);
612
- if (!cur)
613
- errors.push(`'${op.slug}' not found`);
614
- else {
615
- const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
616
- try {
617
- const contract = getContract(currentType);
618
- if (!contract.canPromoteTo.includes(op.target)) {
619
- errors.push(`Cannot promote ${currentType} to ${op.target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
620
- }
621
- else {
622
- simulated.set(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
623
- }
624
- }
625
- catch (err) {
626
- pushErr(err);
627
- }
336
+ },
337
+ existsAnywhere,
338
+ sessionExists: (slug) => existsSync(join(vaultPath, kindDir('session'), `${slug}.md`)) || simulatedSessions.has(slug),
339
+ simulate: (slug, info) => { simulated.set(slug, info); },
340
+ simulateSession: (slug) => { simulatedSessions.add(slug); },
341
+ validateKindFields,
342
+ validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
343
+ buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
344
+ buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
345
+ };
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 = [];
352
+ for (const [index, op] of operations.entries()) {
353
+ // Shape and semantics in one pass: library callers get the same runtime
354
+ // guarantee the CLI's JSON path has always had.
355
+ const shape = BatchOperationSchema.safeParse(op);
356
+ if (!shape.success) {
357
+ parsedOps.push(op);
358
+ const raw = op;
359
+ const label = raw && typeof raw.slug === 'string' ? raw.slug
360
+ : raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
361
+ const entry = {
362
+ index,
363
+ op: typeof raw?.op === 'string' ? raw.op : '?',
364
+ slug: label,
365
+ errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
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';
628
375
  }
629
- }
630
- const entry = { index, op: op.op, slug, errors };
631
- if (created)
376
+ report.push(entry);
377
+ continue;
378
+ }
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 };
384
+ if (res.created)
632
385
  entry.created = true;
633
- if (warning)
634
- entry.warning = warning;
386
+ if (res.warning)
387
+ entry.warning = res.warning;
388
+ if (res.primaryCode)
389
+ entry.primaryCode = res.primaryCode;
635
390
  report.push(entry);
636
391
  }
637
- return { valid: report.every(o => o.errors.length === 0), operations: report };
392
+ return { valid: report.every(o => o.errors.length === 0), operations: report, parsedOps };
638
393
  }
639
394
  function formatOpErrors(report) {
640
- 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)));
641
396
  }
642
397
  async function batch(operations) {
643
- if (readOnly)
644
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
645
- return withLock(async () => {
646
- // Validate the whole payload before touching anything — all errors
647
- // surface at once, localised to their op.
648
- const validation = validateBatchCore(operations);
649
- if (!validation.valid) {
650
- const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
651
- throw new HafezError('VALIDATION_FAILED', `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${operations.length} applied)`, formatOpErrors(validation));
652
- }
653
- // Capture originals for rollback
654
- const originals = new Map();
655
- function captureOriginal(filePath) {
656
- if (!originals.has(filePath)) {
657
- originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
658
- }
659
- }
660
- const modifiedFiles = [];
661
- const affectedSlugs = [];
662
- const results = [];
663
- let currentOp = -1;
664
- try {
665
- for (const op of operations) {
666
- currentOp++;
667
- if (op.op === 'update') {
668
- const filePath = findFile(op.slug);
669
- if (!filePath)
670
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
671
- captureOriginal(filePath);
672
- validateKindFields(kindFromPath(filePath), op.fields);
673
- const { frontmatter: fm, body } = parseFilePath(filePath);
674
- const applied = applyUpdateFields(fm, body, op.fields);
675
- applied.body = syncRelatedSection(applied.body, applied.fm);
676
- writeFileSync(filePath, serializeFile(applied.fm, applied.body));
677
- modifiedFiles.push(relative(vaultPath, filePath));
678
- affectedSlugs.push(op.slug);
679
- results.push({ op: 'update', slug: op.slug, status: 'ok' });
680
- }
681
- else if (op.op === 'create' && op.kind === 'session') {
682
- const slug = slugify(op.name);
683
- if (exists(slug)) {
684
- results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
685
- continue;
686
- }
687
- const sessionDir = join(vaultPath, 'sessions');
688
- mkdirSync(sessionDir, { recursive: true });
689
- const filePath = join(sessionDir, `${slug}.md`);
690
- if (existsSync(filePath)) {
691
- results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
692
- continue;
693
- }
694
- captureOriginal(filePath);
695
- const fm = { name: op.name, created: today() };
696
- const kf = (op.fields ?? {});
697
- if (kf['session-date'])
698
- fm['session-date'] = kf['session-date'];
699
- if (kf.related?.length)
700
- fm.related = kf.related;
701
- // Session files use sections: Summary, Entities Touched, Decisions
702
- const sections = ['Summary', 'Entities Touched', 'Decisions'];
703
- const lines = [];
704
- for (const section of sections) {
705
- lines.push(`## ${section}`);
706
- if (section === 'Summary' && kf.synthesis)
707
- lines.push('', kf.synthesis);
708
- lines.push('');
709
- }
710
- let sessionBody = lines.join('\n');
711
- sessionBody = syncRelatedSection(sessionBody, fm);
712
- writeFileSync(filePath, serializeFile(fm, sessionBody));
713
- modifiedFiles.push(relative(vaultPath, filePath));
714
- // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
715
- // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
716
- results.push({ op: 'create', slug, status: 'ok', created: true });
717
- }
718
- else if (op.op === 'create') {
719
- const slug = slugify(op.name);
720
- if (exists(slug))
721
- throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
722
- if (op.kind === 'entity') {
723
- const ef = op.fields;
724
- const fm = buildEntityFrontmatter(op.name, ef);
725
- let entBody = buildEntityBody(ef);
726
- const filePath = entityPath(slug);
727
- captureOriginal(filePath);
728
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
729
- entBody = syncRelatedSection(entBody, fm);
730
- writeFileSync(filePath, serializeFile(fm, entBody));
731
- modifiedFiles.push(relative(vaultPath, filePath));
732
- affectedSlugs.push(slug);
733
- results.push({ op: 'create', slug, status: 'ok', created: true });
734
- }
735
- else {
736
- const kf = (op.fields || {});
737
- const fm = buildKnowledgeFrontmatter(op.name, kf);
738
- let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
739
- const filePath = knowledgePath(slug);
740
- captureOriginal(filePath);
741
- mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
742
- body = syncRelatedSection(body, fm);
743
- writeFileSync(filePath, serializeFile(fm, body));
744
- modifiedFiles.push(relative(vaultPath, filePath));
745
- affectedSlugs.push(slug);
746
- results.push({ op: 'create', slug, status: 'ok', created: true });
747
- }
748
- }
749
- else if (op.op === 'capture') {
750
- const slug = slugify(op.name);
751
- if (exists(slug))
752
- throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
753
- const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
754
- const filePath = entityPath(slug);
755
- captureOriginal(filePath);
756
- mkdirSync(join(vaultPath, 'entities'), { recursive: true });
757
- writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
758
- modifiedFiles.push(relative(vaultPath, filePath));
759
- affectedSlugs.push(slug);
760
- results.push({ op: 'capture', slug, status: 'ok', created: true });
761
- }
762
- else if (op.op === 'link') {
763
- const filePath = findFile(op.slug);
764
- if (!filePath)
765
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
766
- if (!exists(op.target))
767
- throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
768
- captureOriginal(filePath);
769
- let { frontmatter: fm, body } = parseFilePath(filePath);
770
- if (op.relation === 'parent') {
771
- fm.parent = op.target;
772
- }
773
- else {
774
- const related = fm.related || [];
775
- if (!related.includes(op.target))
776
- related.push(op.target);
777
- fm.related = related;
778
- }
779
- body = syncRelatedSection(body, fm);
780
- writeFileSync(filePath, serializeFile(fm, body));
781
- modifiedFiles.push(relative(vaultPath, filePath));
782
- affectedSlugs.push(op.slug);
783
- results.push({ op: 'link', slug: op.slug, status: 'ok' });
784
- }
785
- else if (op.op === 'unlink') {
786
- const filePath = findFile(op.slug);
787
- if (!filePath)
788
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
789
- captureOriginal(filePath);
790
- let { frontmatter: fm, body } = parseFilePath(filePath);
791
- if (op.relation === 'parent' && fm.parent === op.target)
792
- delete fm.parent;
793
- else if (op.relation === 'related' && fm.related) {
794
- fm.related = fm.related.filter((r) => r !== op.target);
795
- if (fm.related.length === 0)
796
- delete fm.related;
797
- }
798
- body = syncRelatedSection(body, fm);
799
- writeFileSync(filePath, serializeFile(fm, body));
800
- modifiedFiles.push(relative(vaultPath, filePath));
801
- affectedSlugs.push(op.slug);
802
- results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
803
- }
804
- else if (op.op === 'promote') {
805
- const filePath = findFile(op.slug);
806
- if (!filePath)
807
- throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
808
- captureOriginal(filePath);
809
- if (op.target === 'knowledge') {
810
- captureOriginal(knowledgePath(op.slug));
811
- }
812
- const kind = kindFromPath(filePath);
813
- const { frontmatter: fm, body } = parseFilePath(filePath);
814
- const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
815
- modifiedFiles.push(...promoteFiles);
816
- affectedSlugs.push(op.slug);
817
- results.push({ op: 'promote', slug: op.slug, status: 'ok' });
818
- }
819
- }
820
- }
821
- catch (err) {
822
- // Rollback all modified files
823
- for (const [path, content] of originals) {
824
- if (content === null) {
825
- try {
826
- unlinkSync(path);
827
- }
828
- catch { }
829
- }
830
- else {
831
- writeFileSync(path, content);
832
- }
833
- }
834
- // Localise: which op failed, by index and slug/name. The validate
835
- // phase above catches semantic errors pre-execution; this covers
836
- // residual execution-time failures (races, disk errors).
837
- if (err instanceof HafezError && currentOp >= 0) {
838
- const op = operations[currentOp];
839
- const label = 'slug' in op ? op.slug : slugify(op.name);
840
- throw new HafezError(err.code, `op[${currentOp}] (${op.op} ${label}): ${err.message}`, err.details);
841
- }
842
- throw err;
843
- }
844
- // Single git commit for all batch operations
845
- const uniqueFiles = [...new Set(modifiedFiles)];
846
- const indexAfterBatch = () => {
847
- for (const slug of affectedSlugs) {
848
- const fp = findFile(slug);
849
- if (fp) {
850
- getIndex().upsertFromFile(slug, kindFromPath(fp));
851
- }
852
- }
853
- // Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
854
- const anyIndexedTouched = uniqueFiles.some(f => f.startsWith('knowledge/') || f.startsWith('entities/'));
855
- if (anyIndexedTouched) {
856
- regenerateIndexSafe();
857
- }
858
- for (const slug of affectedSlugs)
859
- onWrite?.(slug, 'batch');
860
- };
861
- if (uniqueFiles.length > 0) {
862
- try {
863
- await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
864
- }
865
- catch (err) {
866
- if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
867
- // Commit-stage failure (e.g. a raced .git/index.lock): nothing was
868
- // committed — restore the captured originals so files, git, and
869
- // index stay consistent instead of leaving the batch half-applied.
870
- for (const [path, content] of originals) {
871
- if (content === null) {
872
- try {
873
- unlinkSync(path);
874
- }
875
- catch { }
876
- }
877
- else {
878
- writeFileSync(path, content);
879
- }
880
- }
881
- // Also clear anything the failed commit left staged, or the next
882
- // commit would publish the rolled-back content.
883
- await gitUnstage(vaultPath, uniqueFiles);
884
- throw err;
885
- }
886
- // Push-stage failure: the batch commit exists locally ("changes
887
- // saved locally") — keep it and index it, then surface the error.
888
- indexAfterBatch();
889
- throw err;
890
- }
891
- indexAfterBatch();
892
- }
893
- return results;
894
- });
398
+ const { results } = await runMutations(operations, null);
399
+ return results;
895
400
  }
896
401
  async function validate() {
897
402
  const report = {
@@ -906,16 +411,16 @@ export function createHafez(config) {
906
411
  const knowledgeSlugs = new Set();
907
412
  const referencedKnowledge = new Set();
908
413
  // Scan entities
909
- const entDir = join(vaultPath, 'entities');
910
- if (existsSync(entDir)) {
911
- const files = readdirSync(entDir).filter(f => f.endsWith('.md'));
414
+ const entDir = join(vaultPath, kindDir('entity'));
415
+ {
416
+ const files = scanVaultDir(entDir);
912
417
  report.total_entities = files.length;
913
- for (const file of files) {
914
- const slug = basename(file, '.md');
418
+ for (const filePath of files) {
419
+ const slug = basename(filePath, '.md');
915
420
  allSlugs.add(slug);
916
421
  let fm;
917
422
  try {
918
- fm = parseFilePath(join(entDir, file)).frontmatter;
423
+ fm = parseFilePath(filePath).frontmatter;
919
424
  }
920
425
  catch {
921
426
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
@@ -939,17 +444,17 @@ export function createHafez(config) {
939
444
  }
940
445
  }
941
446
  // Scan knowledge
942
- const knDir = join(vaultPath, 'knowledge');
943
- if (existsSync(knDir)) {
944
- const files = readdirSync(knDir).filter(f => f.endsWith('.md'));
447
+ const knDir = join(vaultPath, kindDir('knowledge'));
448
+ {
449
+ const files = scanVaultDir(knDir);
945
450
  report.total_knowledge = files.length;
946
- for (const file of files) {
947
- const slug = basename(file, '.md');
451
+ for (const filePath of files) {
452
+ const slug = basename(filePath, '.md');
948
453
  allSlugs.add(slug);
949
454
  knowledgeSlugs.add(slug);
950
455
  let fm;
951
456
  try {
952
- fm = parseFilePath(join(knDir, file)).frontmatter;
457
+ fm = parseFilePath(filePath).frontmatter;
953
458
  }
954
459
  catch {
955
460
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
@@ -982,110 +487,14 @@ export function createHafez(config) {
982
487
  }
983
488
  return report;
984
489
  }
985
- function promoteCore(slug, target, filePath, fm, body, kind) {
986
- const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
987
- const contract = getContract(currentType);
988
- if (!contract.canPromoteTo.includes(target)) {
989
- throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
990
- }
991
- const modifiedFiles = [];
992
- if (target === 'knowledge') {
993
- // Cross-kind: move file from entities/ to knowledge/
994
- const newPath = knowledgePath(slug);
995
- const notesSection = findSection(body, 'Notes');
996
- const notesContent = notesSection?.content ?? '';
997
- const newFm = {
998
- name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
999
- };
1000
- if (fm.description)
1001
- newFm.description = fm.description;
1002
- // resource is intentionally dropped on entity→knowledge promote (entity-only field)
1003
- if (fm.domain)
1004
- newFm.domain = fm.domain;
1005
- if (fm.related)
1006
- newFm.related = fm.related;
1007
- if (fm.tags)
1008
- newFm.tags = fm.tags;
1009
- const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
1010
- const finalBody = syncRelatedSection(knBody, newFm);
1011
- writeFileSync(newPath, serializeFile(newFm, finalBody));
1012
- unlinkSync(filePath);
1013
- modifiedFiles.push(relative(vaultPath, newPath));
1014
- // The deletion must be staged too — `git add` of a removed path stages
1015
- // the removal; omitting it leaves the vault permanently dirty.
1016
- modifiedFiles.push(relative(vaultPath, filePath));
1017
- getIndex().removeItem(slug);
1018
- }
1019
- else {
1020
- // Same-kind: capture→entity, capture→project, entity→project
1021
- fm.type = target;
1022
- fm['last-touched'] = today();
1023
- let newBody = body;
1024
- if (target === 'entity') {
1025
- newBody = newBody.replace(/^## Notes$/m, '## Context');
1026
- }
1027
- else {
1028
- // project
1029
- newBody = newBody.replace(/^## Notes$/m, '## Purpose');
1030
- newBody = newBody.replace(/^## Context$/m, '## Purpose');
1031
- if (!newBody.includes('## Goals')) {
1032
- const sections = newBody.split(/\n(?=## )/m);
1033
- const purposeIdx = sections.findIndex(s => s.startsWith('## Purpose'));
1034
- if (purposeIdx !== -1) {
1035
- sections.splice(purposeIdx + 1, 0, '## Goals\n');
1036
- }
1037
- else {
1038
- sections.push('## Goals\n');
1039
- }
1040
- newBody = sections.join('\n');
1041
- }
1042
- }
1043
- if (!newBody.includes('## Session Log'))
1044
- newBody += '\n## Session Log\n';
1045
- // Add promotion session log entry
1046
- const logEntry = `### ${today()} — system [decision]\nSummary: Promoted from ${currentType} to ${target}\n`;
1047
- const logIdx = newBody.lastIndexOf('## Session Log');
1048
- if (logIdx !== -1) {
1049
- const insertAt = logIdx + '## Session Log'.length + 1;
1050
- newBody = newBody.slice(0, insertAt) + '\n' + logEntry + newBody.slice(insertAt);
1051
- }
1052
- newBody = syncRelatedSection(newBody, fm);
1053
- writeFileSync(filePath, serializeFile(fm, newBody));
1054
- modifiedFiles.push(relative(vaultPath, filePath));
1055
- }
1056
- return { modifiedFiles };
1057
- }
1058
490
  async function promote(slug, target) {
1059
- if (readOnly)
1060
- throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1061
- return withLock(async () => {
1062
- const filePath = findFile(slug);
1063
- if (!filePath)
1064
- throw new HafezError('NOT_FOUND', `'${slug}' not found`);
1065
- const kind = kindFromPath(filePath);
1066
- // Capture original content for rollback before parsing (avoids double read)
1067
- const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
1068
- const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
1069
- const { frontmatter: fm, body } = parseFilePath(filePath);
1070
- const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
1071
- try {
1072
- const { modifiedFiles } = promoteCore(slug, target, filePath, fm, body, kind);
1073
- await gitCommitAndPush(vaultPath, modifiedFiles, `promote: ${slug} ${sourceType} → ${target}`, gitConfig);
1074
- getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
1075
- regenerateIndexSafe();
1076
- onWrite?.(slug, 'promote');
1077
- }
1078
- catch (err) {
1079
- if (target === 'knowledge' && originalContent) {
1080
- writeFileSync(filePath, originalContent);
1081
- // Never unlink the file we just restored (SEC-4)
1082
- if (knowledgeTarget && knowledgeTarget !== filePath && existsSync(knowledgeTarget))
1083
- unlinkSync(knowledgeTarget);
1084
- }
1085
- throw err;
1086
- }
1087
- return slug;
1088
- });
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;
1089
498
  }
1090
499
  const emptyStats = {
1091
500
  counts: { active: 0, paused: 0, done: 0 },
@@ -1097,59 +506,38 @@ export function createHafez(config) {
1097
506
  read,
1098
507
  update,
1099
508
  promote,
1100
- query: async (opts) => {
1101
- const idx = getIndex();
1102
- if (!idx)
1103
- return { items: [], total: 0 };
1104
- return queryEntities(idx, opts);
1105
- },
1106
- queryUnified: async (opts) => {
1107
- const idx = getIndex();
1108
- if (!idx)
1109
- return { items: [], total: 0 };
1110
- return queryUnified(idx, opts);
1111
- },
509
+ query: async (opts) => withIndex(idx => idx.queryEntities(opts), { items: [], total: 0 }),
510
+ queryUnified: async (opts) => withIndex(idx => idx.queryUnified(opts), { items: [], total: 0 }),
1112
511
  create,
1113
512
  capture,
1114
513
  link,
1115
514
  unlink,
1116
515
  batch,
1117
- 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
+ },
1118
523
  sync: async () => {
1119
524
  if (readOnly)
1120
525
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1121
- return withLock(() => gitSync(vaultPath));
1122
- },
1123
- children: async (slug) => {
1124
- const idx = getIndex();
1125
- if (!idx)
1126
- return { items: [], total: 0 };
1127
- return queryChildren(idx, slug);
1128
- },
1129
- related_to: async (slug, relation) => {
1130
- const idx = getIndex();
1131
- if (!idx)
1132
- return { items: [], total: 0 };
1133
- return queryRelatedTo(idx, slug, relation);
1134
- },
1135
- query_knowledge: async (opts) => {
1136
- const idx = getIndex();
1137
- if (!idx)
1138
- return { items: [], total: 0 };
1139
- return queryKnowledge(idx, opts);
526
+ return withLock(() => journal.sync());
1140
527
  },
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 }),
1141
531
  validate,
1142
- search: async (query, kind) => {
1143
- const idx = getIndex();
1144
- if (!idx)
1145
- return [];
1146
- idx.syncIfStale();
1147
- return idx.search(query, kind);
1148
- },
532
+ search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
1149
533
  rebuildIndex: async () => {
1150
534
  if (readOnly)
1151
535
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1152
- 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(); });
1153
541
  },
1154
542
  stats: async () => {
1155
543
  const idx = getIndex();
@@ -1161,10 +549,9 @@ export function createHafez(config) {
1161
549
  s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
1162
550
  return s;
1163
551
  },
1164
- changelog: (since) => gitChangelog(vaultPath, since),
552
+ changelog: (since) => journal.changelog(since),
1165
553
  };
1166
554
  }
1167
555
  export { HafezError } from './types.js';
1168
- export { getBrief, getNextActions, findSection } from './sections.js';
1169
- export { parseSessionLogHeading, parseSessionLog } from './parser.js';
1170
- export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
556
+ export { getBrief, getNextActions } from './document.js';
557
+ export { parseSessionLogHeading, parseSessionLog } from './document.js';