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/apply.js ADDED
@@ -0,0 +1,693 @@
1
+ // src/apply.ts
2
+ // Apply (see CONTEXT.md): the Spine's apply stage — one arm per Op Spec row,
3
+ // behind a dispatch table keyed by the same names. The table's key type is
4
+ // derived from the Op Spec's, so a spec row with no arm here fails to build:
5
+ // adding an op is one Op Spec entry plus one entry in APPLY.
6
+ //
7
+ // Apply trusts the Spine: every semantic gate ran exactly once, in the
8
+ // validate phase (the Op Spec's check), so no arm re-checks a rule. Branches
9
+ // validation has ruled out are bare internal throws — a Spine bug must be loud
10
+ // in the test suite, not masquerade as caller error. The branches that are
11
+ // NOT internal are the two directions of the validate→apply race, where an
12
+ // external actor removed a file the op resolved (vanished()) or created one
13
+ // the op claimed (appeared()); both throw caller-facing.
14
+ //
15
+ // Apply owns every vault-file write and nothing else — it touches neither git
16
+ // nor SQLite. The Spine commits [...filesWritten, ...filesDeleted] under
17
+ // commitMessage and runs the index updates afterwards. The Op Spec table stays
18
+ // pure (no filesystem access); the frontmatter builders live here and are
19
+ // exported so the batch validate phase can run them against a simulated vault.
20
+ import { writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
21
+ import { join, relative, dirname } from 'path';
22
+ import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
23
+ import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
24
+ import { bodyTemplate, knowledgeBodyTemplate, sessionBodyTemplate } from './templates.js';
25
+ import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
26
+ import { sectionsFor, statusesFor, unreachableFields, describeTarget } from './contracts.js';
27
+ import { localDay } from './dates.js';
28
+ import { detectAgent } from './harness.js';
29
+ import { specFor, loggedFields } from './batch-ops.js';
30
+ import { HafezError } from './types.js';
31
+ /** A vault file that validation resolved but is gone at apply time. The vault
32
+ * lock excludes other hafez processes, not external actors (Obsidian, a hand
33
+ * rm, a git checkout), so unlike a branch validation has truly ruled out —
34
+ * which would stay a bare internal throw, loud in the test suite — this is a
35
+ * recoverable race: a caller-facing NOT_FOUND naming the actionable fact, and
36
+ * a HafezError so batch mode keeps its op[i] localisation. */
37
+ function vanished(slug) {
38
+ throw new HafezError('NOT_FOUND', `'${slug}' vanished after validation — something outside hafez removed it; re-run`);
39
+ }
40
+ /** The mirror image: a vault file validation confirmed absent, but which
41
+ * exists at apply time. Same external actors, same window — and a create arm
42
+ * that wrote anyway would overwrite a file hafez never saw, which rollback
43
+ * only undoes when some LATER op in the batch happens to fail. Guarding one
44
+ * direction of the race and not the other reads as intentional to the next
45
+ * person, so both directions throw. */
46
+ function appeared(slug) {
47
+ throw new HafezError('SLUG_EXISTS', `'${slug}' appeared after validation — something outside hafez created it; nothing was overwritten, re-run`);
48
+ }
49
+ function entityPath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'entity'); }
50
+ function knowledgePath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
51
+ function today() { return localDay(); }
52
+ // --- Field policy ---
53
+ const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'agent', 'resource', 'parent'];
54
+ const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
55
+ // An entity-only field is still reachable on a knowledge note when THAT
56
+ // note's contract declares the capability the field needs. The gate asks the
57
+ // contract (contracts.ts), not the field name, so the plan/insight split is
58
+ // one table entry rather than a special case threaded through the paths.
59
+ const KNOWLEDGE_FIELD_CAPABILITY = {
60
+ status: t => statusesFor(t).length > 0,
61
+ };
62
+ /** Reject a status the target's contract does not define (a plan has no
63
+ * 'paused'; an insight has no status at all). One check for create and
64
+ * update alike. */
65
+ function assertStatusAllowed(target, status) {
66
+ const allowed = statusesFor(target);
67
+ if (allowed.length === 0) {
68
+ throw new HafezError('VALIDATION_FAILED', `Cannot set status on ${describeTarget(target)}: status is plan-subtype only`);
69
+ }
70
+ if (!allowed.includes(status)) {
71
+ throw new HafezError('VALIDATION_FAILED', `Invalid status '${status}' for ${describeTarget(target)} (expected: ${allowed.join(', ')})`);
72
+ }
73
+ }
74
+ /** Content-field policy: reject, never route, never drop. A create field
75
+ * writes into a named body section; when the chosen template has no such
76
+ * section the text would vanish on write, so the op fails loudly instead.
77
+ * Generic over the (template, field) pair — see contracts.unreachableFields. */
78
+ function assertFieldsReachable(target, fields) {
79
+ const unreachable = unreachableFields(target, fields);
80
+ if (unreachable.length === 0)
81
+ return;
82
+ const named = unreachable.map(u => `'${u.field}' (needs a '## ${u.section}' section)`).join(', ');
83
+ throw new HafezError('VALIDATION_FAILED', `Cannot set ${named} on ${describeTarget(target)} — its template has no such section, and text is never silently dropped. Sections available: ${sectionsFor(target).join(', ')}`);
84
+ }
85
+ export function validateKindFields(kind, fields, subtype) {
86
+ if (kind === 'knowledge') {
87
+ const target = { kind: 'knowledge', subtype };
88
+ const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined && !(KNOWLEDGE_FIELD_CAPABILITY[f]?.(target) ?? false));
89
+ if (invalid.length > 0) {
90
+ throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
91
+ }
92
+ if (fields.status !== undefined)
93
+ assertStatusAllowed(target, fields.status);
94
+ }
95
+ if (kind === 'entity') {
96
+ const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
97
+ if (invalid.length > 0) {
98
+ throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
99
+ }
100
+ }
101
+ }
102
+ // --- Builders ---
103
+ // Shared by the create arms and the batch validate phase so optional fields
104
+ // can't silently diverge between paths (see CLAUDE.md Validation Gotchas).
105
+ // `existsFn` is the caller's: apply passes the real vault, validateBatchCore a
106
+ // simulation-aware check (same-batch creates count as existing).
107
+ export function buildEntityFrontmatter(name, ef, existsFn) {
108
+ assertFieldsReachable({ kind: 'entity', type: ef.type }, ef);
109
+ const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
110
+ if (ef.domain?.length)
111
+ fm.domain = ef.domain;
112
+ if (ef.parent) {
113
+ if (!existsFn(ef.parent))
114
+ throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
115
+ fm.parent = ef.parent;
116
+ }
117
+ if (ef.related?.length) {
118
+ for (const r of ef.related) {
119
+ if (!existsFn(r))
120
+ throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
121
+ }
122
+ fm.related = ef.related;
123
+ }
124
+ if (ef.tags?.length)
125
+ fm.tags = ef.tags;
126
+ if (ef.description)
127
+ fm.description = ef.description;
128
+ if (ef.resource)
129
+ fm.resource = ef.resource;
130
+ const errors = validateEntityFrontmatter(fm);
131
+ if (errors.length > 0)
132
+ throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
133
+ return fm;
134
+ }
135
+ // Every action add — create, update, batch, digest — lands here, so this is
136
+ // the one place the added-date stamp is written (local day, never twice).
137
+ function addStampedAction(doc, body, action) {
138
+ return doc.addNextAction(body, stampAction(action, today()));
139
+ }
140
+ // Body-side optional fields (brief, actions) must behave identically in both
141
+ // paths, like buildEntityFrontmatter's.
142
+ function buildEntityBody(ef) {
143
+ const doc = documentFor({ kind: 'entity' });
144
+ let body = bodyTemplate(ef.type, { purpose: ef.purpose });
145
+ if (ef.brief)
146
+ body = doc.setBrief(body, ef.brief);
147
+ if (ef.add_action !== undefined)
148
+ body = addStampedAction(doc, body, ef.add_action);
149
+ for (const action of ef.add_actions ?? [])
150
+ body = addStampedAction(doc, body, action);
151
+ return body;
152
+ }
153
+ export function buildKnowledgeFrontmatter(name, kf, existsFn) {
154
+ const target = { kind: 'knowledge', subtype: kf.subtype };
155
+ assertFieldsReachable(target, kf);
156
+ const fm = { name, confidence: kf.confidence ?? 'observation', 'reinforcement-count': 0, created: today() };
157
+ if (kf.domain?.length)
158
+ fm.domain = kf.domain;
159
+ if (kf.related?.length) {
160
+ for (const r of kf.related) {
161
+ if (!existsFn(r))
162
+ throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
163
+ }
164
+ fm.related = kf.related;
165
+ }
166
+ if (kf.tags?.length)
167
+ fm.tags = kf.tags;
168
+ if (kf.description)
169
+ fm.description = kf.description;
170
+ if (kf.status !== undefined) {
171
+ // Plans are tracked work: they carry a lifecycle and therefore a
172
+ // last-touched day, exactly like entities do.
173
+ assertStatusAllowed(target, kf.status);
174
+ fm.status = kf.status;
175
+ fm['last-touched'] = today();
176
+ }
177
+ const errors = validateKnowledgeFrontmatter(fm);
178
+ if (errors.length > 0)
179
+ throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
180
+ if (kf.subtype)
181
+ fm.subtype = kf.subtype;
182
+ if (kf['session-date'])
183
+ fm['session-date'] = kf['session-date'];
184
+ return fm;
185
+ }
186
+ // The Next Actions fields are the one group in UpdateFields that all touch
187
+ // the same section, so their order within an op IS their meaning. The
188
+ // contract: prune, tick, add — clear_actions → remove_action →
189
+ // complete_action → add_action → add_actions. Every query is resolved
190
+ // against the list as it stands after the clear and before any edit, so a
191
+ // query never sees a line another field of the same op removed or added;
192
+ // two queries naming one line is a collision, not a silent overwrite. The
193
+ // section is pruned once at the end, after the adds — a tick that empties
194
+ // the open list and an add in the same op edit the section in place rather
195
+ // than removing and recreating it. The "rebuild the list" payload
196
+ // (`clear_actions` + `add_actions`) does what it reads as. Contradictory
197
+ // payloads (clear + a query) are rejected in the validate phase, not here.
198
+ //
199
+ // Lines leaving the list go to the archive log, never nowhere: the removed
200
+ // line, a cleared list, the ticked lines a prune takes with the section, and
201
+ // — once the section holds ARCHIVE_LIMIT or more ticked lines — the oldest of
202
+ // them, leaving one fewer than the limit (the session log's rule). Each line
203
+ // takes the lines nested under it (up to the next checkbox line). Run on
204
+ // every update, so a list grown by hand is trimmed by the next write; never
205
+ // on read.
206
+ const ARCHIVE_LIMIT = 10;
207
+ function applyNextActionFields(doc, body, fields) {
208
+ const result = {};
209
+ const cleared = fields.clear_actions ? doc.actionLines(body) : [];
210
+ let removedLine;
211
+ if (fields.clear_actions)
212
+ body = doc.clearNextActions(body);
213
+ const removed = fields.remove_action !== undefined ? doc.matchNextAction(body, fields.remove_action) : undefined;
214
+ const completed = fields.complete_action !== undefined ? doc.matchNextAction(body, fields.complete_action) : undefined;
215
+ // Caller misuse, not a Spine bug: like no-match and ambiguous (matchNextAction),
216
+ // it depends on the list as the earlier ops of a batch leave it, so it is
217
+ // resolved here rather than in the validate phase.
218
+ if (removed && completed && removed.line === completed.line) {
219
+ throw new HafezError('VALIDATION_FAILED', `remove_action and complete_action both match "${removed.text}" — one line, one field`);
220
+ }
221
+ if (removed)
222
+ result.removed_action = removed.text;
223
+ if (completed)
224
+ result.completed_action = completed.text;
225
+ // Both matches are line positions in the pre-edit list. A tick leaves every
226
+ // position where it was; a removal shifts the lines below it — so tick first.
227
+ if (completed)
228
+ body = doc.completeNextAction(body, completed).body;
229
+ if (removed)
230
+ ({ body, line: removedLine } = doc.removeNextAction(body, removed));
231
+ if (fields.add_action !== undefined)
232
+ body = addStampedAction(doc, body, fields.add_action);
233
+ if (fields.add_actions !== undefined) {
234
+ for (const action of fields.add_actions) {
235
+ body = addStampedAction(doc, body, action);
236
+ }
237
+ }
238
+ const overflow = doc.extractOldestTickedActions(body, ARCHIVE_LIMIT - 1);
239
+ // With no open line left the prune takes the section, ticked lines and all.
240
+ const pruned = doc.getNextActions(overflow.body).length === 0 ? doc.actionLines(overflow.body) : [];
241
+ return {
242
+ body: doc.pruneNextActions(overflow.body),
243
+ result,
244
+ archived: { cleared, removed: removedLine, ticked: [...overflow.lines, ...pruned] },
245
+ };
246
+ }
247
+ // Pure field application, like every arm above it: the Spine has already run
248
+ // Op Spec validation (shape + semantics). `doc` is the Document bound to the
249
+ // file's kind (see documentFor).
250
+ function applyUpdateFields(doc, fm, body, fields) {
251
+ if (fields.status !== undefined)
252
+ fm.status = fields.status;
253
+ if (fields.current_state !== undefined) {
254
+ body = doc.setSection(body, 'Current State', fields.current_state, 'Session Log');
255
+ }
256
+ if (fields.brief !== undefined) {
257
+ body = fields.brief === null ? doc.removeBrief(body) : doc.setBrief(body, fields.brief);
258
+ }
259
+ const actions = applyNextActionFields(doc, body, fields);
260
+ body = actions.body;
261
+ if (fields.session_log) {
262
+ body = doc.prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
263
+ }
264
+ // Domain (shared) + knowledge-only metadata
265
+ if (fields.domain !== undefined) {
266
+ if (fields.domain.length === 0)
267
+ delete fm.domain;
268
+ else
269
+ fm.domain = fields.domain;
270
+ }
271
+ if (fields.description !== undefined) {
272
+ if (fields.description === '' || fields.description === null)
273
+ delete fm.description;
274
+ else
275
+ fm.description = fields.description;
276
+ }
277
+ if (fields.resource !== undefined) {
278
+ if (fields.resource === '' || fields.resource === null)
279
+ delete fm.resource;
280
+ else
281
+ fm.resource = fields.resource;
282
+ }
283
+ if (fields.confidence !== undefined)
284
+ fm.confidence = fields.confidence;
285
+ if (fields.parent !== undefined)
286
+ fm.parent = fields.parent;
287
+ if (fields.tags !== undefined)
288
+ fm.tags = fields.tags;
289
+ if (fields.related !== undefined)
290
+ fm.related = fields.related;
291
+ if (fields.synthesis !== undefined) {
292
+ body = doc.setSection(body, 'Synthesis', fields.synthesis);
293
+ }
294
+ if (fields.add_evidence !== undefined) {
295
+ const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
296
+ body = doc.appendToSection(body, 'Evidence', sanitized);
297
+ fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
298
+ fm['last-reinforced'] = today();
299
+ // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
300
+ // removed. Confidence changes are now a deliberate decision via update --confidence.
301
+ }
302
+ if (fields.add_source !== undefined) {
303
+ body = doc.appendToSection(body, 'Sources', fields.add_source);
304
+ }
305
+ if (fm['last-touched'] !== today())
306
+ fm['last-touched'] = today();
307
+ const updateResult = { ...actions.result, ...(fields.session_log && { logged: true }) };
308
+ return { fm, body, updateResult, archivedActions: archivedActionEntries(actions.archived) };
309
+ }
310
+ /** The archive log's entries for action lines leaving the list. Entry-shaped
311
+ * like the session log entries archived beside them, so the file stays one
312
+ * entry stream (merge.ts unions it by entry). */
313
+ function archivedActionEntries(archived) {
314
+ const entries = [];
315
+ const plural = (n) => n === 1 ? '' : 's';
316
+ if (archived.cleared.length > 0) {
317
+ const n = archived.cleared.length;
318
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Cleared ${n} action${plural(n)}`, body: archived.cleared.join('\n') }));
319
+ }
320
+ if (archived.removed !== undefined) {
321
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: archived.removed }));
322
+ }
323
+ const n = archived.ticked.length;
324
+ if (n > 0) {
325
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Archived ${n} ticked action${plural(n)}`, body: archived.ticked.join('\n') }));
326
+ }
327
+ return entries;
328
+ }
329
+ /** Mechanised session log (#85, moved into the Spine by #107): an entity
330
+ * update with no session_log gets a `progress` entry naming the fields
331
+ * written, so history is complete whichever caller wrote it. Action-only
332
+ * updates are exempt, and only entities carry a Session Log. */
333
+ function withMechanisedLog(kind, fields) {
334
+ if (kind !== 'entity' || fields.session_log)
335
+ return fields;
336
+ const logged = loggedFields(fields);
337
+ if (logged.length === 0)
338
+ return fields;
339
+ return {
340
+ ...fields,
341
+ session_log: {
342
+ type: 'progress',
343
+ summary: `Updated ${logged.map(f => f.replace(/_/g, '-')).join(', ')}`,
344
+ agent: fields.agent ?? detectAgent(),
345
+ },
346
+ };
347
+ }
348
+ // --- The arms ---
349
+ function applyUpdate(op, ctx) {
350
+ const { vaultPath } = ctx;
351
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
352
+ ctx.captureOriginal(filePath);
353
+ const kind = kindFromPath(filePath);
354
+ const parsedFile = parseFilePath(filePath);
355
+ const doc = documentForFile(parsedFile);
356
+ let fm = parsedFile.frontmatter;
357
+ let body = parsedFile.body;
358
+ const applied = applyUpdateFields(doc, fm, body, withMechanisedLog(kind, op.fields));
359
+ fm = applied.fm;
360
+ body = applied.body;
361
+ // Archival: action lines that left the list, and the oldest session log entry
362
+ const archiveEntries = [...applied.archivedActions];
363
+ if (doc.countSessionLogEntries(body) >= ARCHIVE_LIMIT) {
364
+ const extracted = doc.extractOldestSessionLogEntry(body);
365
+ if (extracted) {
366
+ archiveEntries.push(extracted.entry);
367
+ body = extracted.remaining;
368
+ }
369
+ }
370
+ const archivePath = archiveLogPath(vaultPath, op.slug);
371
+ const wroteArchive = archiveEntries.length > 0;
372
+ if (wroteArchive) {
373
+ ctx.captureOriginal(archivePath);
374
+ mkdirSync(dirname(archivePath), { recursive: true });
375
+ appendFileSync(archivePath, archiveEntries.map(e => e + '\n\n').join(''));
376
+ }
377
+ body = doc.syncRelatedSection(body, fm);
378
+ writeFileSync(filePath, serializeFile(fm, body));
379
+ // Only the files THIS update wrote. Keying off the archive file merely
380
+ // existing swept any external hand-edit sitting in entities/archive/
381
+ // into an unrelated commit.
382
+ const files = [relative(vaultPath, filePath)];
383
+ if (wroteArchive)
384
+ files.push(relative(vaultPath, archivePath));
385
+ return {
386
+ filesWritten: files,
387
+ filesDeleted: [],
388
+ result: { op: 'update', slug: op.slug, status: 'ok' },
389
+ commitMessage: `update: ${op.slug}`,
390
+ indexRemovals: [],
391
+ indexUpserts: [{ slug: op.slug, kind }],
392
+ updateResult: applied.updateResult,
393
+ };
394
+ }
395
+ function applyCreateEntity(op, ctx) {
396
+ const { vaultPath } = ctx;
397
+ const slug = slugify(op.name);
398
+ const ef = op.fields;
399
+ const fm = buildEntityFrontmatter(op.name, ef, s => vaultFileExists(vaultPath, s));
400
+ let body = buildEntityBody(ef);
401
+ const filePath = entityPath(vaultPath, slug);
402
+ // Re-checked against the same cross-kind test claimNew ran (vaultFileExists
403
+ // is the real-disk half of the Spine's existsAnywhere) — not a re-litigation
404
+ // of the rule, just the race window.
405
+ if (vaultFileExists(vaultPath, slug))
406
+ appeared(slug);
407
+ ctx.captureOriginal(filePath);
408
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
409
+ body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
410
+ writeFileSync(filePath, serializeFile(fm, body));
411
+ return {
412
+ filesWritten: [relative(vaultPath, filePath)],
413
+ filesDeleted: [],
414
+ result: { op: 'create', slug, status: 'ok', created: true },
415
+ commitMessage: `create: ${op.name}`,
416
+ indexRemovals: [],
417
+ indexUpserts: [{ slug, kind: 'entity' }],
418
+ };
419
+ }
420
+ function applyCreateKnowledge(op, ctx) {
421
+ const { vaultPath } = ctx;
422
+ const slug = slugify(op.name);
423
+ const kf = (op.fields || {});
424
+ const fm = buildKnowledgeFrontmatter(op.name, kf, s => vaultFileExists(vaultPath, s));
425
+ let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
426
+ const filePath = knowledgePath(vaultPath, slug);
427
+ if (vaultFileExists(vaultPath, slug))
428
+ appeared(slug);
429
+ ctx.captureOriginal(filePath);
430
+ mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
431
+ body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
432
+ writeFileSync(filePath, serializeFile(fm, body));
433
+ return {
434
+ filesWritten: [relative(vaultPath, filePath)],
435
+ filesDeleted: [],
436
+ result: { op: 'create', slug, status: 'ok', created: true },
437
+ commitMessage: `create: ${op.name}`,
438
+ indexRemovals: [],
439
+ indexUpserts: [{ slug, kind: 'knowledge' }],
440
+ };
441
+ }
442
+ function applyCreateSession(op, ctx) {
443
+ const { vaultPath } = ctx;
444
+ const slug = slugify(op.name);
445
+ // A slug taken at validate time never reaches here: that is the
446
+ // create-session spec's skip disposition (a warning, not an error), and the
447
+ // Spine synthesizes the result without calling Apply. One taken DURING the
448
+ // validate→apply window does reach here, and overwriting someone else's
449
+ // session note is worse than the error — so the race throws even though the
450
+ // ordinary duplicate warns. Mirrors the spec's own `existsAnywhere ||
451
+ // sessionExists` test.
452
+ const sessionDir = join(vaultPath, kindDir('session'));
453
+ mkdirSync(sessionDir, { recursive: true });
454
+ const filePath = join(sessionDir, `${slug}.md`);
455
+ if (existsSync(filePath) || vaultFileExists(vaultPath, slug))
456
+ appeared(slug);
457
+ ctx.captureOriginal(filePath);
458
+ const fm = { name: op.name, created: today() };
459
+ const kf = (op.fields ?? {});
460
+ if (kf['session-date'])
461
+ fm['session-date'] = kf['session-date'];
462
+ if (kf.related?.length)
463
+ fm.related = kf.related;
464
+ // Session sections come from contracts.SESSION_SECTIONS — the same list
465
+ // Document splits on, so the written body and the structural set agree.
466
+ let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
467
+ sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
468
+ writeFileSync(filePath, serializeFile(fm, sessionBody));
469
+ // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
470
+ // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
471
+ return {
472
+ filesWritten: [relative(vaultPath, filePath)],
473
+ filesDeleted: [],
474
+ result: { op: 'create', slug, status: 'ok', created: true },
475
+ commitMessage: `create: ${op.name}`,
476
+ indexRemovals: [],
477
+ indexUpserts: [],
478
+ };
479
+ }
480
+ function applyCapture(op, ctx) {
481
+ const { vaultPath } = ctx;
482
+ const slug = slugify(op.name);
483
+ const fm = {
484
+ name: op.name,
485
+ type: 'capture',
486
+ status: 'active',
487
+ created: today(),
488
+ 'last-touched': today(),
489
+ };
490
+ const filePath = entityPath(vaultPath, slug);
491
+ if (vaultFileExists(vaultPath, slug))
492
+ appeared(slug);
493
+ ctx.captureOriginal(filePath);
494
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
495
+ writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
496
+ return {
497
+ filesWritten: [relative(vaultPath, filePath)],
498
+ filesDeleted: [],
499
+ result: { op: 'capture', slug, status: 'ok', created: true },
500
+ commitMessage: `capture: ${op.name}`,
501
+ indexRemovals: [],
502
+ indexUpserts: [{ slug, kind: 'entity' }],
503
+ };
504
+ }
505
+ function applyLink(op, ctx) {
506
+ const { vaultPath } = ctx;
507
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
508
+ ctx.captureOriginal(filePath);
509
+ const parsedFile = parseFilePath(filePath);
510
+ const fm = parsedFile.frontmatter;
511
+ let body = parsedFile.body;
512
+ if (op.relation === 'parent') {
513
+ fm.parent = op.target;
514
+ }
515
+ else {
516
+ const related = fm.related || [];
517
+ if (!related.includes(op.target))
518
+ related.push(op.target);
519
+ fm.related = related;
520
+ }
521
+ body = documentForFile(parsedFile).syncRelatedSection(body, fm);
522
+ writeFileSync(filePath, serializeFile(fm, body));
523
+ return {
524
+ filesWritten: [relative(vaultPath, filePath)],
525
+ filesDeleted: [],
526
+ result: { op: 'link', slug: op.slug, status: 'ok' },
527
+ commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
528
+ indexRemovals: [],
529
+ indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
530
+ };
531
+ }
532
+ function applyUnlink(op, ctx) {
533
+ const { vaultPath } = ctx;
534
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
535
+ ctx.captureOriginal(filePath);
536
+ const parsedFile = parseFilePath(filePath);
537
+ const fm = parsedFile.frontmatter;
538
+ let body = parsedFile.body;
539
+ if (op.relation === 'parent' && fm.parent === op.target) {
540
+ delete fm.parent;
541
+ }
542
+ else if (op.relation === 'related' && fm.related) {
543
+ fm.related = fm.related.filter((r) => r !== op.target);
544
+ if (fm.related.length === 0)
545
+ delete fm.related;
546
+ }
547
+ body = documentForFile(parsedFile).syncRelatedSection(body, fm);
548
+ writeFileSync(filePath, serializeFile(fm, body));
549
+ return {
550
+ filesWritten: [relative(vaultPath, filePath)],
551
+ filesDeleted: [],
552
+ result: { op: 'unlink', slug: op.slug, status: 'ok' },
553
+ commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
554
+ indexRemovals: [],
555
+ indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
556
+ };
557
+ }
558
+ function applyPromote(op, ctx) {
559
+ const { vaultPath } = ctx;
560
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
561
+ ctx.captureOriginal(filePath);
562
+ if (op.target === 'knowledge')
563
+ ctx.captureOriginal(knowledgePath(vaultPath, op.slug));
564
+ const kind = kindFromPath(filePath);
565
+ const parsedFile = parseFilePath(filePath);
566
+ const fm = parsedFile.frontmatter;
567
+ const body = parsedFile.body;
568
+ const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
569
+ const modifiedFiles = [];
570
+ const deletedFiles = [];
571
+ if (op.target === 'knowledge') {
572
+ // Cross-kind: move file from entities/ to knowledge/. The source body
573
+ // is read under the entity Document binding.
574
+ const sourceDoc = documentFor({ kind: 'entity' });
575
+ const newPath = knowledgePath(vaultPath, op.slug);
576
+ // Promote must behave like create AT THE TARGET. A diverged vault can
577
+ // hold the same slug as both an entity and a knowledge note (findFile
578
+ // resolves entities first, so the validate phase sees only the entity);
579
+ // without this check the write below silently destroys the note.
580
+ if (existsSync(newPath)) {
581
+ throw new HafezError('SLUG_EXISTS', `Slug '${op.slug}' already exists in ${kindDir('knowledge')}/`);
582
+ }
583
+ const notesSection = sourceDoc.findSection(body, 'Notes');
584
+ const notesContent = notesSection?.content ?? '';
585
+ const newFm = {
586
+ name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
587
+ };
588
+ if (fm.description)
589
+ newFm.description = fm.description;
590
+ // resource is intentionally dropped on entity→knowledge promote (entity-only field)
591
+ if (fm.domain)
592
+ newFm.domain = fm.domain;
593
+ if (fm.related)
594
+ newFm.related = fm.related;
595
+ if (fm.tags)
596
+ newFm.tags = fm.tags;
597
+ const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
598
+ const finalBody = documentFor({ kind: 'knowledge' }).syncRelatedSection(knBody, newFm);
599
+ // An entities-only vault has no knowledge/ yet — create it like the
600
+ // create-knowledge arm does, and map any fs failure onto a HafezError
601
+ // so no raw ENOENT/ENOTDIR escapes the promote path.
602
+ try {
603
+ mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
604
+ writeFileSync(newPath, serializeFile(newFm, finalBody));
605
+ unlinkSync(filePath);
606
+ }
607
+ catch (err) {
608
+ throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
609
+ }
610
+ modifiedFiles.push(relative(vaultPath, newPath));
611
+ // The deletion must be staged too — `git add` of a removed path stages
612
+ // the removal; omitting it leaves the vault permanently dirty.
613
+ deletedFiles.push(relative(vaultPath, filePath));
614
+ }
615
+ else {
616
+ // Same-kind: capture→entity, capture→project, entity→project
617
+ fm.type = op.target;
618
+ fm['last-touched'] = today();
619
+ // All body reshaping goes through Document — structural sections only,
620
+ // so quoted heading mentions inside content can no longer suppress or
621
+ // hijack the splice points.
622
+ const doc = documentFor({ kind: 'entity' });
623
+ let newBody = body;
624
+ if (op.target === 'entity') {
625
+ newBody = doc.renameSection(newBody, 'Notes', 'Context');
626
+ }
627
+ else {
628
+ // project
629
+ newBody = doc.renameSection(newBody, 'Notes', 'Purpose');
630
+ newBody = doc.renameSection(newBody, 'Context', 'Purpose');
631
+ newBody = doc.ensureSection(newBody, 'Goals', { after: 'Purpose' });
632
+ }
633
+ newBody = doc.prependSessionLogEntry(newBody, formatSessionLogEntry({
634
+ type: 'decision',
635
+ agent: 'system',
636
+ summary: `Promoted from ${sourceType} to ${op.target}`,
637
+ }));
638
+ newBody = doc.syncRelatedSection(newBody, fm);
639
+ writeFileSync(filePath, serializeFile(fm, newBody));
640
+ modifiedFiles.push(relative(vaultPath, filePath));
641
+ }
642
+ return {
643
+ filesWritten: modifiedFiles,
644
+ filesDeleted: deletedFiles,
645
+ result: { op: 'promote', slug: op.slug, status: 'ok' },
646
+ commitMessage: `promote: ${op.slug} ${sourceType} → ${op.target}`,
647
+ indexRemovals: op.target === 'knowledge' ? [op.slug] : [],
648
+ indexUpserts: [{ slug: op.slug, kind: op.target === 'knowledge' ? 'knowledge' : 'entity' }],
649
+ };
650
+ }
651
+ function applyDelete(op, ctx) {
652
+ const { vaultPath } = ctx;
653
+ // By path, not findVaultFile's entity-first order: the Op Spec ruled out
654
+ // an entity, and resolved the slug to exactly one of these two.
655
+ const knowledgeFile = knowledgePath(vaultPath, op.slug);
656
+ const kind = existsSync(knowledgeFile) ? 'knowledge' : 'session';
657
+ const filePath = kind === 'knowledge' ? knowledgeFile : resolveFilePath(vaultPath, op.slug, 'session');
658
+ if (!existsSync(filePath))
659
+ vanished(op.slug);
660
+ // The snapshot is the rollback: a commit-stage failure writes it back.
661
+ ctx.captureOriginal(filePath);
662
+ unlinkSync(filePath);
663
+ return {
664
+ filesWritten: [],
665
+ filesDeleted: [relative(vaultPath, filePath)],
666
+ result: { op: 'delete', slug: op.slug, status: 'ok' },
667
+ commitMessage: `delete: ${op.slug} (${kind})`,
668
+ // Sessions are not indexed; a knowledge row goes with its file.
669
+ indexRemovals: kind === 'knowledge' ? [op.slug] : [],
670
+ indexUpserts: [],
671
+ deletedKind: kind,
672
+ };
673
+ }
674
+ const APPLY = {
675
+ 'update': applyUpdate,
676
+ 'create-entity': applyCreateEntity,
677
+ 'create-knowledge': applyCreateKnowledge,
678
+ 'create-session': applyCreateSession,
679
+ 'capture': applyCapture,
680
+ 'link': applyLink,
681
+ 'unlink': applyUnlink,
682
+ 'promote': applyPromote,
683
+ 'delete': applyDelete,
684
+ };
685
+ /** The single apply path for every mutation. Writes vault files but never
686
+ * touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]
687
+ * under commitMessage (single ops) or one composed batch message, then runs
688
+ * the index updates. ctx.captureOriginal snapshots files for rollback before
689
+ * they change. */
690
+ export function applyOperation(op, ctx) {
691
+ const arm = APPLY[specFor(op).name];
692
+ return arm(op, ctx);
693
+ }