hafez 1.3.0 → 2.0.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 (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. package/skill/SKILL.md +0 -196
package/dist/index.js CHANGED
@@ -1,17 +1,17 @@
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, isIndexedRelPath, findVaultFile, vaultFileExists, resolveFilePath, scanVaultDir, INDEXED_KINDS } from './vault.js';
3
3
  import { createGitJournal } from './git.js';
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';
4
+ import { validateSessionLogEntry } from './schema.js';
5
+ import { validateVault } from './validate.js';
6
+ import { documentForFile } from './document.js';
9
7
  import { createIndex } from './db.js';
8
+ import { closestSlugs } from './closest.js';
10
9
  import { generateVaultIndex } from './knowledge-index.js';
11
10
  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';
11
+ import { BatchOperationSchema, specFor, slugBearingOpNames, sessionAddressingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
12
+ import { applyOperation, buildEntityFrontmatter, buildKnowledgeFrontmatter, validateKindFields } from './apply.js';
13
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
14
+ import { join, basename } from 'path';
15
15
  import { lock as lockVault } from 'proper-lockfile';
16
16
  export function createHafez(config) {
17
17
  const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
@@ -51,215 +51,28 @@ export function createHafez(config) {
51
51
  catch { /* stale lock reclaimed by another process */ }
52
52
  }
53
53
  }
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
- }
54
+ // Layout answers where a slug lives; these bind the vault path once.
55
+ function findFile(slug) { return findVaultFile(vaultPath, slug); }
56
+ function exists(slug) { return vaultFileExists(vaultPath, slug); }
93
57
  function regenerateIndexSafe() {
94
58
  // Runs post-commit: the vault write is already persisted/pushed, so an
95
59
  // index.md failure must warn, never reject the operation.
96
60
  try {
97
- generateVaultIndex(vaultPath);
61
+ // index.md is rendered from the rows updateIndexPostCommit just
62
+ // refreshed, not from a second full-vault read: everything it shows
63
+ // (name, status, type, confidence, description, domain, body-derived
64
+ // summary) is already indexed. syncIfStale first so files changed
65
+ // outside hafez — a hand edit, a git pull — are represented too; that
66
+ // costs a stat per file, where the old path paid a parse per file on
67
+ // every single mutation.
68
+ const idx = getIndex();
69
+ idx?.syncIfStale();
70
+ generateVaultIndex(vaultPath, idx?.indexEntries());
98
71
  }
99
72
  catch (err) {
100
73
  process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
101
74
  }
102
75
  }
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
76
  let _index = null;
264
77
  let _indexInitialized = false;
265
78
  function getIndex() {
@@ -272,21 +85,30 @@ export function createHafez(config) {
272
85
  }
273
86
  return _index;
274
87
  }
88
+ // The one null-index guard: a read-only instance may have no on-disk index
89
+ // yet — reads then return their empty shape instead of crashing.
90
+ function withIndex(fn, empty) {
91
+ const idx = getIndex();
92
+ return idx ? fn(idx) : empty;
93
+ }
275
94
  async function read(slug, depth = 'summary') {
276
95
  const filePath = findFile(slug);
277
- if (!filePath)
278
- throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
96
+ if (!filePath) {
97
+ // A miss carries the closest slugs as its details: the recovery the
98
+ // `read` miss output renders (#91).
99
+ const closest = withIndex(idx => { idx.syncIfStale(); return closestSlugs(slug, idx.indexEntries()); }, []);
100
+ throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
101
+ }
279
102
  const parsed = parseFilePath(filePath);
280
103
  // findFile only resolves the indexed kinds, so the session arm is unreachable
281
- const kind = parsed.kind === 'session' ? 'knowledge' : parsed.kind;
282
- const frontmatter = parsed.frontmatter;
283
- const body = parsed.body;
104
+ if (parsed.kind === 'session')
105
+ throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
284
106
  if (depth === 'frontmatter')
285
- return { kind, frontmatter, body: '' };
107
+ return { ...parsed, body: '' };
286
108
  if (depth === 'summary') {
287
- return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
109
+ return { ...parsed, body: documentForFile(parsed).bodyBeforeSessionLog(parsed.body) };
288
110
  }
289
- return { kind, frontmatter, body };
111
+ return parsed;
290
112
  }
291
113
  function rollbackFiles(originals) {
292
114
  for (const [path, content] of originals) {
@@ -313,301 +135,146 @@ export function createHafez(config) {
313
135
  getIndex().upsertFromFile(u.slug, u.kind);
314
136
  }
315
137
  }
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;
138
+ async function runMutations(operations, single) {
139
+ if (readOnly)
140
+ throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
141
+ // Declared aliases (insight → synthesis) become canonical before anything
142
+ // else reads the payload: validate and apply must never see different
143
+ // field names, or an alias validates ok and then applies as nothing.
144
+ operations = normalizeOperations(operations);
145
+ return withLock(async () => {
146
+ // Validate the whole payload before touching anything — all errors
147
+ // surface at once, localised to their op. Single ops surface their
148
+ // primary error class (NOT_FOUND / SLUG_EXISTS) as the thrown code.
149
+ const validation = validateBatchCore(operations);
150
+ if (!validation.valid) {
151
+ if (single) {
152
+ const opv = validation.operations[0];
153
+ 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
154
  }
155
+ const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
156
+ throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(invalidCount, operations.length), formatOpErrors(validation));
345
157
  }
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
- };
158
+ // Apply receives the shape's output, not the raw payload, so a shape
159
+ // transform lands in the file instead of existing only in the
160
+ // validator's view.
161
+ operations = validation.parsedOps;
162
+ // Capture originals for rollback
163
+ const originals = new Map();
164
+ function captureOriginal(filePath) {
165
+ if (!originals.has(filePath)) {
166
+ originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
167
+ }
431
168
  }
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;
169
+ const applied = [];
170
+ const results = [];
171
+ let currentOp = -1;
172
+ try {
173
+ for (const op of operations) {
174
+ currentOp++;
175
+ // A warned op is the validate phase's skip disposition (today: a
176
+ // duplicate session — green-lit so the batch runs on, but nothing
177
+ // to write). The Spine synthesizes the result rather than calling
178
+ // Apply, so the rule lives only in the Op Spec. `code` rides along
179
+ // for the single-op unwrap: that caller has nothing to return and
180
+ // must throw under the target-exists class, not a generic one.
181
+ const warning = validation.operations[currentOp].warning;
182
+ if (warning) {
183
+ results.push({
184
+ op: op.op, slug: validation.operations[currentOp].slug,
185
+ status: 'skipped', warning, code: 'SLUG_EXISTS',
186
+ });
187
+ continue;
188
+ }
189
+ const a = applyOperation(op, { vaultPath, captureOriginal });
190
+ results.push(a.result);
191
+ if (a.result.status === 'ok')
192
+ applied.push(a);
193
+ }
485
194
  }
486
- else {
487
- const related = fm.related || [];
488
- if (!related.includes(op.target))
489
- related.push(op.target);
490
- fm.related = related;
195
+ catch (err) {
196
+ rollbackFiles(originals);
197
+ // The validate phase above catches semantic errors pre-execution;
198
+ // this localises residual execution-time failures (races, disk
199
+ // errors) to their op in batch mode.
200
+ if (!single && err instanceof HafezError && currentOp >= 0) {
201
+ const op = operations[currentOp];
202
+ const label = 'slug' in op ? op.slug : slugify(op.name);
203
+ throw new HafezError(err.code, opErrorLine(currentOp, op.op, label, err.message), err.details);
204
+ }
205
+ throw err;
491
206
  }
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) }],
207
+ // Batch reports per-op non-'ok' results; a single-op caller unwraps
208
+ // applied[0], so anything that did not apply must surface as a thrown
209
+ // error, not a TypeError on the unwrap — under the result's own error
210
+ // class where it has one.
211
+ if (single && applied.length === 0) {
212
+ const failed = results.find(r => r.status !== 'ok');
213
+ throw new HafezError(failed?.code ?? 'VALIDATION_FAILED', failed?.error || failed?.warning || 'operation was not applied');
214
+ }
215
+ const writtenAll = [...new Set(applied.flatMap(a => a.filesWritten))];
216
+ const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
217
+ const finalize = () => {
218
+ updateIndexPostCommit(applied);
219
+ // index.md regeneration is gated: session-only writes skip it
220
+ if ([...writtenAll, ...deletedAll].some(isIndexedRelPath)) {
221
+ regenerateIndexSafe();
222
+ }
223
+ // Session creates carry no index upserts — they are not indexed
224
+ // entities and never notified.
225
+ for (const a of applied) {
226
+ if (a.indexUpserts.length > 0)
227
+ onWrite?.(a.result.slug, single ? single.action : 'batch');
228
+ }
501
229
  };
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;
230
+ if (writtenAll.length + deletedAll.length > 0) {
231
+ const message = single ? applied[0].commitMessage : `batch: ${operations.length} operations`;
232
+ try {
233
+ await journal.commit(writtenAll, deletedAll, message);
234
+ }
235
+ catch (err) {
236
+ if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
237
+ // Commit-stage failure (e.g. a raced .git/index.lock): nothing was
238
+ // committed — restore the captured originals so files, journal,
239
+ // and index stay consistent. The adapter has already cleaned up
240
+ // its own partial state (staged files) before rethrowing.
241
+ rollbackFiles(originals);
242
+ throw err;
243
+ }
244
+ // Push-stage failure: the commit exists locally ("changes saved
245
+ // locally") — keep it and index it, then surface the error.
246
+ finalize();
247
+ throw err;
248
+ }
249
+ finalize();
518
250
  }
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
- };
251
+ return { applied, results };
252
+ });
551
253
  }
552
254
  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
- });
255
+ const op = kind === 'entity'
256
+ ? { op: 'create', kind, name, fields: fields }
257
+ : { op: 'create', kind, name, fields: fields };
258
+ const { applied } = await runMutations([op], { action: 'create' });
259
+ return applied[0].result.slug;
565
260
  }
566
261
  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
- });
262
+ const { applied } = await runMutations([{ op: 'capture', name, notes }], { action: 'capture' });
263
+ return applied[0].result.slug;
577
264
  }
578
265
  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
- });
266
+ const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
267
+ return applied[0].updateResult ?? {};
589
268
  }
590
269
  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
- });
270
+ await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
600
271
  }
601
272
  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
- });
273
+ await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
274
+ }
275
+ async function del(slug) {
276
+ const { applied } = await runMutations([{ op: 'delete', slug }], { action: 'delete' });
277
+ return applied[0].deletedKind;
611
278
  }
612
279
  // "Recent" with day-granularity dates: primary sort is the frontmatter day,
613
280
  // ties break on git commit time (the real recency signal), then name for
@@ -645,10 +312,36 @@ export function createHafez(config) {
645
312
  // slug → what it would be after earlier ops in this batch
646
313
  const simulated = new Map();
647
314
  const simulatedSessions = new Set();
648
- const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
315
+ // Deletes earlier in the batch: the slug is absent to every later op, so
316
+ // delete-then-create of one slug is a rewrite, not a collision.
317
+ const deleted = new Set();
318
+ const deletedSessions = new Set();
319
+ // slug → its parent/related after the earlier ops; files absent here
320
+ // carry what is on disk.
321
+ const simulatedLinks = new Map();
322
+ const existsAnywhere = (slug) => !deleted.has(slug) && (simulated.has(slug) || exists(slug));
323
+ const linksOf = (slug) => {
324
+ const sim = simulatedLinks.get(slug);
325
+ if (sim)
326
+ return sim;
327
+ const fp = findFile(slug);
328
+ if (!fp)
329
+ return {};
330
+ try {
331
+ const pf = parseFilePath(fp);
332
+ if (pf.kind === 'entity')
333
+ return { parent: pf.frontmatter.parent, related: pf.frontmatter.related };
334
+ return { related: pf.frontmatter.related };
335
+ }
336
+ catch {
337
+ return {};
338
+ }
339
+ };
649
340
  // Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
650
341
  const ctx = {
651
342
  resolve: (slug) => {
343
+ if (deleted.has(slug))
344
+ return null;
652
345
  const sim = simulated.get(slug);
653
346
  if (sim)
654
347
  return sim;
@@ -656,8 +349,18 @@ export function createHafez(config) {
656
349
  if (!fp)
657
350
  return null;
658
351
  const kind = kindFromPath(fp);
659
- if (kind === 'knowledge')
660
- return { kind, type: 'knowledge' };
352
+ // The subtype decides which fields a knowledge note accepts (plan vs
353
+ // insight), so it is resolved here rather than re-read per op.
354
+ if (kind === 'knowledge') {
355
+ try {
356
+ const pf = parseFilePath(fp);
357
+ const subtype = pf.kind === 'knowledge' ? pf.frontmatter.subtype : undefined;
358
+ return { kind, type: 'knowledge', subtype };
359
+ }
360
+ catch {
361
+ return { kind, type: 'knowledge' };
362
+ }
363
+ }
661
364
  try {
662
365
  const pf = parseFilePath(fp);
663
366
  return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
@@ -667,328 +370,120 @@ export function createHafez(config) {
667
370
  }
668
371
  },
669
372
  existsAnywhere,
670
- sessionExists: (slug) => existsSync(join(vaultPath, kindDir('session'), `${slug}.md`)) || simulatedSessions.has(slug),
671
- simulate: (slug, info) => { simulated.set(slug, info); },
672
- simulateSession: (slug) => { simulatedSessions.add(slug); },
373
+ // resolveFilePath, not a bare join: delete acts on this answer, and a
374
+ // '../' slug must never reach a file outside sessions/.
375
+ sessionExists: (slug) => {
376
+ if (deletedSessions.has(slug))
377
+ return false;
378
+ if (simulatedSessions.has(slug))
379
+ return true;
380
+ try {
381
+ return existsSync(resolveFilePath(vaultPath, slug, 'session'));
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ },
387
+ simulate: (slug, info) => { deleted.delete(slug); simulated.set(slug, info); },
388
+ simulateSession: (slug) => { deletedSessions.delete(slug); simulatedSessions.add(slug); },
389
+ linksOf,
390
+ simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
391
+ // Read from disk, not the index: the validate phase must see the vault
392
+ // as `validate` will, and a stale index row would let a linked note go.
393
+ referrers: (slug) => {
394
+ const candidates = new Set(simulated.keys());
395
+ for (const kind of INDEXED_KINDS) {
396
+ for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
397
+ candidates.add(basename(fp, '.md'));
398
+ }
399
+ return [...candidates]
400
+ .filter(from => from !== slug && !deleted.has(from))
401
+ .filter(from => { const l = linksOf(from); return l.parent === slug || (l.related ?? []).includes(slug); })
402
+ .sort();
403
+ },
404
+ simulateDelete: (slug, kind) => {
405
+ if (kind === 'session') {
406
+ deletedSessions.add(slug);
407
+ simulatedSessions.delete(slug);
408
+ return;
409
+ }
410
+ deleted.add(slug);
411
+ simulated.delete(slug);
412
+ // A file created under this slug later in the batch starts unlinked.
413
+ simulatedLinks.set(slug, {});
414
+ },
673
415
  validateKindFields,
674
416
  validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
675
417
  buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
676
418
  buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
677
419
  };
678
420
  const report = [];
421
+ // The shape's OUTPUT, index-aligned with the report. Shapes may transform
422
+ // (action/evidence text is trimmed), and the semantic checks and Apply
423
+ // must both see what the shape emitted — validating the trimmed value but
424
+ // writing the raw one would store what validation never looked at.
425
+ const parsedOps = [];
679
426
  for (const [index, op] of operations.entries()) {
680
427
  // Shape and semantics in one pass: library callers get the same runtime
681
428
  // guarantee the CLI's JSON path has always had.
682
429
  const shape = BatchOperationSchema.safeParse(op);
683
430
  if (!shape.success) {
431
+ parsedOps.push(op);
684
432
  const raw = op;
685
433
  const label = raw && typeof raw.slug === 'string' ? raw.slug
686
434
  : raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
687
- report.push({
435
+ const entry = {
688
436
  index,
689
437
  op: typeof raw?.op === 'string' ? raw.op : '?',
690
438
  slug: label,
691
439
  errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
692
- });
440
+ };
441
+ // A missing slug outranks field-shape problems: spec.check never runs
442
+ // when the shape fails, but the direct paths always surfaced NOT_FOUND
443
+ // first (and scripts branch on its exit code) — keep that precedence.
444
+ if (raw && typeof raw.slug === 'string' && raw.slug &&
445
+ slugBearingOpNames().includes(entry.op) &&
446
+ !existsAnywhere(raw.slug) &&
447
+ !(sessionAddressingOpNames().includes(entry.op) && ctx.sessionExists(raw.slug))) {
448
+ entry.errors.unshift(notFoundError(raw.slug));
449
+ entry.primaryCode = 'NOT_FOUND';
450
+ }
451
+ report.push(entry);
693
452
  continue;
694
453
  }
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 };
454
+ const parsed = shape.data;
455
+ parsedOps.push(parsed);
456
+ const spec = specFor(parsed);
457
+ const res = spec.check(parsed, ctx);
458
+ const entry = { index, op: parsed.op, slug: res.slug, errors: res.errors };
698
459
  if (res.created)
699
460
  entry.created = true;
700
461
  if (res.warning)
701
462
  entry.warning = res.warning;
463
+ if (res.primaryCode)
464
+ entry.primaryCode = res.primaryCode;
702
465
  report.push(entry);
703
466
  }
704
- return { valid: report.every(o => o.errors.length === 0), operations: report };
467
+ return { valid: report.every(o => o.errors.length === 0), operations: report, parsedOps };
705
468
  }
706
469
  function formatOpErrors(report) {
707
- return report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
470
+ return report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
708
471
  }
709
472
  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
- });
473
+ const { results } = await runMutations(operations, null);
474
+ return results;
791
475
  }
792
476
  async function validate() {
793
- const report = {
794
- broken_slugs: [],
795
- orphaned_knowledge: [],
796
- oversized_related: [],
797
- missing_fields: [],
798
- total_entities: 0,
799
- total_knowledge: 0,
800
- };
801
- const allSlugs = new Set();
802
- const knowledgeSlugs = new Set();
803
- const referencedKnowledge = new Set();
804
- // Scan entities
805
- const entDir = join(vaultPath, kindDir('entity'));
806
- {
807
- const files = scanVaultDir(entDir);
808
- report.total_entities = files.length;
809
- for (const filePath of files) {
810
- const slug = basename(filePath, '.md');
811
- allSlugs.add(slug);
812
- let fm;
813
- try {
814
- fm = parseFilePath(filePath).frontmatter;
815
- }
816
- catch {
817
- report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
818
- continue;
819
- }
820
- const errors = validateEntityFrontmatter(fm);
821
- for (const e of errors)
822
- report.missing_fields.push({ slug, field: '', issue: e });
823
- if (fm.parent) {
824
- if (!exists(fm.parent))
825
- report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
826
- }
827
- if (fm.related && Array.isArray(fm.related)) {
828
- for (const r of fm.related) {
829
- if (!exists(r))
830
- report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
831
- // Track referenced knowledge
832
- referencedKnowledge.add(r);
833
- }
834
- }
835
- }
836
- }
837
- // Scan knowledge
838
- const knDir = join(vaultPath, kindDir('knowledge'));
839
- {
840
- const files = scanVaultDir(knDir);
841
- report.total_knowledge = files.length;
842
- for (const filePath of files) {
843
- const slug = basename(filePath, '.md');
844
- allSlugs.add(slug);
845
- knowledgeSlugs.add(slug);
846
- let fm;
847
- try {
848
- fm = parseFilePath(filePath).frontmatter;
849
- }
850
- catch {
851
- report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
852
- continue;
853
- }
854
- const errors = validateKnowledgeFrontmatter(fm);
855
- for (const e of errors)
856
- report.missing_fields.push({ slug, field: '', issue: e });
857
- if (fm.related && Array.isArray(fm.related)) {
858
- for (const r of fm.related) {
859
- if (!exists(r))
860
- report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
861
- }
862
- }
863
- }
864
- }
865
- // Find orphaned knowledge: no related, and not referenced by any entity
866
- for (const slug of knowledgeSlugs) {
867
- let fm;
868
- try {
869
- fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
870
- }
871
- catch {
872
- continue; // already reported above
873
- }
874
- const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
875
- if (!hasRelated && !referencedKnowledge.has(slug)) {
876
- report.orphaned_knowledge.push(slug);
877
- }
878
- }
879
- return report;
880
- }
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 };
477
+ return validateVault(vaultPath);
953
478
  }
954
479
  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
- });
480
+ // Rollback safety rides the Spine: applyOperation's promote arm captures
481
+ // both the source file and (for cross-kind moves) the knowledge target
482
+ // before writing, so a commit-stage failure restores the source and
483
+ // removes the created file (SEC-4: never unlink a restored path —
484
+ // rollbackFiles only unlinks paths captured as previously-absent).
485
+ await runMutations([{ op: 'promote', slug, target }], { action: 'promote' });
486
+ return slug;
992
487
  }
993
488
  const emptyStats = {
994
489
  counts: { active: 0, paused: 0, done: 0 },
@@ -1000,59 +495,39 @@ export function createHafez(config) {
1000
495
  read,
1001
496
  update,
1002
497
  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
- },
498
+ query: async (opts) => withIndex(idx => idx.queryEntities(opts), { items: [], total: 0 }),
499
+ queryUnified: async (opts) => withIndex(idx => idx.queryUnified(opts), { items: [], total: 0 }),
1015
500
  create,
1016
501
  capture,
1017
502
  link,
1018
503
  unlink,
504
+ delete: del,
1019
505
  batch,
1020
- validateBatch: async (operations) => validateBatchCore(operations),
506
+ // Same normalization as the apply path — dry-run must judge the payload
507
+ // apply would actually run.
508
+ validateBatch: async (operations) => {
509
+ // parsedOps is the Spine's internal handoff, not part of the report.
510
+ const { valid, operations: report } = validateBatchCore(normalizeOperations(operations));
511
+ return { valid, operations: report };
512
+ },
1021
513
  sync: async () => {
1022
514
  if (readOnly)
1023
515
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1024
516
  return withLock(() => journal.sync());
1025
517
  },
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
- },
518
+ children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
519
+ related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
520
+ query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
1044
521
  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
- },
522
+ search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
1052
523
  rebuildIndex: async () => {
1053
524
  if (readOnly)
1054
525
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
1055
- getIndex().rebuild();
526
+ // A rebuild reads every vault file and rewrites the whole index, so it
527
+ // takes the same two-level write lock every mutation takes — otherwise it
528
+ // can interleave with a mutation's file writes and index a half-written
529
+ // vault.
530
+ await withLock(async () => { getIndex().rebuild(); });
1056
531
  },
1057
532
  stats: async () => {
1058
533
  const idx = getIndex();
@@ -1068,5 +543,5 @@ export function createHafez(config) {
1068
543
  };
1069
544
  }
1070
545
  export { HafezError } from './types.js';
1071
- export { getBrief, getNextActions, findSection } from './document.js';
546
+ export { getBrief, getNextActions } from './document.js';
1072
547
  export { parseSessionLogHeading, parseSessionLog } from './document.js';