hafez 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +67 -9
  2. package/dist/apply.d.ts +30 -0
  3. package/dist/apply.js +582 -0
  4. package/dist/batch-ops.d.ts +470 -0
  5. package/dist/batch-ops.js +446 -0
  6. package/dist/cli/args.d.ts +19 -0
  7. package/dist/cli/args.js +53 -0
  8. package/dist/cli/batch-errors.js +6 -3
  9. package/dist/cli/commands.d.ts +24 -108
  10. package/dist/cli/commands.js +297 -313
  11. package/dist/cli/help-agent.d.ts +0 -5
  12. package/dist/cli/help-agent.js +44 -5
  13. package/dist/cli/index.d.ts +41 -0
  14. package/dist/cli/index.js +172 -172
  15. package/dist/cli/resolve-vault.js +2 -1
  16. package/dist/cli/schema-introspect.d.ts +1 -1
  17. package/dist/cli/schema-introspect.js +6 -22
  18. package/dist/cli/upgrade.d.ts +42 -0
  19. package/dist/cli/upgrade.js +305 -0
  20. package/dist/cli.js +0 -0
  21. package/dist/contracts.d.ts +55 -6
  22. package/dist/contracts.js +110 -23
  23. package/dist/dates.d.ts +10 -0
  24. package/dist/dates.js +27 -0
  25. package/dist/db.d.ts +27 -2
  26. package/dist/db.js +318 -56
  27. package/dist/digest.js +2 -0
  28. package/dist/document.d.ts +117 -0
  29. package/dist/document.js +578 -0
  30. package/dist/export-okf.js +48 -34
  31. package/dist/git.d.ts +7 -8
  32. package/dist/git.js +198 -63
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +302 -915
  35. package/dist/knowledge-index.d.ts +23 -1
  36. package/dist/knowledge-index.js +71 -13
  37. package/dist/merge.d.ts +1 -1
  38. package/dist/merge.js +106 -86
  39. package/dist/schema.js +4 -4
  40. package/dist/templates.d.ts +6 -0
  41. package/dist/templates.js +27 -27
  42. package/dist/types.d.ts +86 -12
  43. package/dist/vault.d.ts +51 -6
  44. package/dist/vault.js +114 -11
  45. package/package.json +2 -2
  46. package/skill/SKILL.md +51 -110
  47. package/dist/parser.d.ts +0 -18
  48. package/dist/parser.js +0 -68
  49. package/dist/query.d.ts +0 -24
  50. package/dist/query.js +0 -167
  51. package/dist/sections.d.ts +0 -54
  52. package/dist/sections.js +0 -297
package/dist/apply.js ADDED
@@ -0,0 +1,582 @@
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, documentFor, documentForFile } from './document.js';
26
+ import { sectionsFor, statusesFor, unreachableFields, describeTarget } from './contracts.js';
27
+ import { localDay } from './dates.js';
28
+ import { specFor } from './batch-ops.js';
29
+ import { HafezError } from './types.js';
30
+ /** A vault file that validation resolved but is gone at apply time. The vault
31
+ * lock excludes other hafez processes, not external actors (Obsidian, a hand
32
+ * rm, a git checkout), so unlike a branch validation has truly ruled out —
33
+ * which would stay a bare internal throw, loud in the test suite — this is a
34
+ * recoverable race: a caller-facing NOT_FOUND naming the actionable fact, and
35
+ * a HafezError so batch mode keeps its op[i] localisation. */
36
+ function vanished(slug) {
37
+ throw new HafezError('NOT_FOUND', `'${slug}' vanished after validation — something outside hafez removed it; re-run`);
38
+ }
39
+ /** The mirror image: a vault file validation confirmed absent, but which
40
+ * exists at apply time. Same external actors, same window — and a create arm
41
+ * that wrote anyway would overwrite a file hafez never saw, which rollback
42
+ * only undoes when some LATER op in the batch happens to fail. Guarding one
43
+ * direction of the race and not the other reads as intentional to the next
44
+ * person, so both directions throw. */
45
+ function appeared(slug) {
46
+ throw new HafezError('SLUG_EXISTS', `'${slug}' appeared after validation — something outside hafez created it; nothing was overwritten, re-run`);
47
+ }
48
+ function entityPath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'entity'); }
49
+ function knowledgePath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
50
+ function today() { return localDay(); }
51
+ // --- Field policy ---
52
+ const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
53
+ const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
54
+ // An entity-only field is still reachable on a knowledge note when THAT
55
+ // note's contract declares the capability the field needs. The gate asks the
56
+ // contract (contracts.ts), not the field name, so the plan/insight split is
57
+ // one table entry rather than a special case threaded through the paths.
58
+ const KNOWLEDGE_FIELD_CAPABILITY = {
59
+ status: t => statusesFor(t).length > 0,
60
+ };
61
+ /** Reject a status the target's contract does not define (a plan has no
62
+ * 'paused'; an insight has no status at all). One check for create and
63
+ * update alike. */
64
+ function assertStatusAllowed(target, status) {
65
+ const allowed = statusesFor(target);
66
+ if (allowed.length === 0) {
67
+ throw new HafezError('VALIDATION_FAILED', `Cannot set status on ${describeTarget(target)}: status is plan-subtype only`);
68
+ }
69
+ if (!allowed.includes(status)) {
70
+ throw new HafezError('VALIDATION_FAILED', `Invalid status '${status}' for ${describeTarget(target)} (expected: ${allowed.join(', ')})`);
71
+ }
72
+ }
73
+ /** Content-field policy: reject, never route, never drop. A create field
74
+ * writes into a named body section; when the chosen template has no such
75
+ * section the text would vanish on write, so the op fails loudly instead.
76
+ * Generic over the (template, field) pair — see contracts.unreachableFields. */
77
+ function assertFieldsReachable(target, fields) {
78
+ const unreachable = unreachableFields(target, fields);
79
+ if (unreachable.length === 0)
80
+ return;
81
+ const named = unreachable.map(u => `'${u.field}' (needs a '## ${u.section}' section)`).join(', ');
82
+ 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(', ')}`);
83
+ }
84
+ export function validateKindFields(kind, fields, subtype) {
85
+ if (kind === 'knowledge') {
86
+ const target = { kind: 'knowledge', subtype };
87
+ const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined && !(KNOWLEDGE_FIELD_CAPABILITY[f]?.(target) ?? false));
88
+ if (invalid.length > 0) {
89
+ throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
90
+ }
91
+ if (fields.status !== undefined)
92
+ assertStatusAllowed(target, fields.status);
93
+ }
94
+ if (kind === 'entity') {
95
+ const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
96
+ if (invalid.length > 0) {
97
+ throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
98
+ }
99
+ }
100
+ }
101
+ // --- Builders ---
102
+ // Shared by the create arms and the batch validate phase so optional fields
103
+ // can't silently diverge between paths (see CLAUDE.md Validation Gotchas).
104
+ // `existsFn` is the caller's: apply passes the real vault, validateBatchCore a
105
+ // simulation-aware check (same-batch creates count as existing).
106
+ export function buildEntityFrontmatter(name, ef, existsFn) {
107
+ assertFieldsReachable({ kind: 'entity', type: ef.type }, ef);
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
+ // Body-side optional fields (brief, actions) must behave identically in both
135
+ // paths, like buildEntityFrontmatter's.
136
+ function buildEntityBody(ef) {
137
+ const doc = documentFor({ kind: 'entity' });
138
+ let body = bodyTemplate(ef.type, { purpose: ef.purpose });
139
+ if (ef.brief)
140
+ body = doc.setBrief(body, ef.brief);
141
+ if (ef.add_action !== undefined)
142
+ body = doc.addNextAction(body, ef.add_action);
143
+ for (const action of ef.add_actions ?? [])
144
+ body = doc.addNextAction(body, action);
145
+ return body;
146
+ }
147
+ export function buildKnowledgeFrontmatter(name, kf, existsFn) {
148
+ const target = { kind: 'knowledge', subtype: kf.subtype };
149
+ assertFieldsReachable(target, kf);
150
+ const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
151
+ if (kf.domain?.length)
152
+ fm.domain = kf.domain;
153
+ if (kf.related?.length) {
154
+ for (const r of kf.related) {
155
+ if (!existsFn(r))
156
+ throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
157
+ }
158
+ fm.related = kf.related;
159
+ }
160
+ if (kf.tags?.length)
161
+ fm.tags = kf.tags;
162
+ if (kf.description)
163
+ fm.description = kf.description;
164
+ if (kf.status !== undefined) {
165
+ // Plans are tracked work: they carry a lifecycle and therefore a
166
+ // last-touched day, exactly like entities do.
167
+ assertStatusAllowed(target, kf.status);
168
+ fm.status = kf.status;
169
+ fm['last-touched'] = today();
170
+ }
171
+ const errors = validateKnowledgeFrontmatter(fm);
172
+ if (errors.length > 0)
173
+ throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
174
+ if (kf.subtype)
175
+ fm.subtype = kf.subtype;
176
+ if (kf['session-date'])
177
+ fm['session-date'] = kf['session-date'];
178
+ return fm;
179
+ }
180
+ // Pure field application, like every arm above it: the Spine has already run
181
+ // Op Spec validation (shape + semantics). `doc` is the Document bound to the
182
+ // file's kind (see documentFor).
183
+ function applyUpdateFields(doc, fm, body, fields) {
184
+ if (fields.status !== undefined)
185
+ fm.status = fields.status;
186
+ if (fields.current_state !== undefined) {
187
+ body = doc.setSection(body, 'Current State', fields.current_state, 'Session Log');
188
+ }
189
+ if (fields.brief !== undefined) {
190
+ body = fields.brief === null ? doc.removeBrief(body) : doc.setBrief(body, fields.brief);
191
+ }
192
+ let matchedAction;
193
+ if (fields.add_action !== undefined)
194
+ body = doc.addNextAction(body, fields.add_action);
195
+ if (fields.add_actions !== undefined) {
196
+ for (const action of fields.add_actions) {
197
+ body = doc.addNextAction(body, action);
198
+ }
199
+ }
200
+ if (fields.complete_action !== undefined) {
201
+ const r = doc.completeNextAction(body, fields.complete_action);
202
+ body = r.body;
203
+ matchedAction = r.matched;
204
+ }
205
+ if (fields.remove_action !== undefined) {
206
+ const r = doc.removeNextAction(body, fields.remove_action);
207
+ body = r.body;
208
+ matchedAction = r.matched;
209
+ }
210
+ if (fields.clear_actions)
211
+ body = doc.clearNextActions(body);
212
+ if (fields.session_log) {
213
+ body = doc.prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
214
+ }
215
+ // Domain (shared) + knowledge-only metadata
216
+ if (fields.domain !== undefined) {
217
+ if (fields.domain.length === 0)
218
+ delete fm.domain;
219
+ else
220
+ fm.domain = fields.domain;
221
+ }
222
+ if (fields.description !== undefined) {
223
+ if (fields.description === '' || fields.description === null)
224
+ delete fm.description;
225
+ else
226
+ fm.description = fields.description;
227
+ }
228
+ if (fields.resource !== undefined) {
229
+ if (fields.resource === '' || fields.resource === null)
230
+ delete fm.resource;
231
+ else
232
+ fm.resource = fields.resource;
233
+ }
234
+ if (fields.confidence !== undefined)
235
+ fm.confidence = fields.confidence;
236
+ if (fields.tags !== undefined)
237
+ fm.tags = fields.tags;
238
+ if (fields.related !== undefined)
239
+ fm.related = fields.related;
240
+ if (fields.synthesis !== undefined) {
241
+ body = doc.setSection(body, 'Synthesis', fields.synthesis);
242
+ }
243
+ if (fields.add_evidence !== undefined) {
244
+ const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
245
+ body = doc.appendToSection(body, 'Evidence', sanitized);
246
+ fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
247
+ fm['last-reinforced'] = today();
248
+ // NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
249
+ // removed. Confidence changes are now a deliberate decision via update --confidence.
250
+ }
251
+ if (fields.add_source !== undefined) {
252
+ body = doc.appendToSection(body, 'Sources', fields.add_source);
253
+ }
254
+ if (fm['last-touched'] !== today())
255
+ fm['last-touched'] = today();
256
+ return { fm, body, matchedAction };
257
+ }
258
+ function promoteCore(ctx, slug, target, filePath, fm, body, kind) {
259
+ const { vaultPath } = ctx;
260
+ const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
261
+ const modifiedFiles = [];
262
+ const deletedFiles = [];
263
+ if (target === 'knowledge') {
264
+ // Cross-kind: move file from entities/ to knowledge/. The source body
265
+ // is read under the entity Document binding.
266
+ const sourceDoc = documentFor({ kind: 'entity' });
267
+ const newPath = knowledgePath(vaultPath, slug);
268
+ // Promote must behave like create AT THE TARGET. A diverged vault can
269
+ // hold the same slug as both an entity and a knowledge note (findFile
270
+ // resolves entities first, so the validate phase sees only the entity);
271
+ // without this check the write below silently destroys the note.
272
+ if (existsSync(newPath)) {
273
+ throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists in ${kindDir('knowledge')}/`);
274
+ }
275
+ const notesSection = sourceDoc.findSection(body, 'Notes');
276
+ const notesContent = notesSection?.content ?? '';
277
+ const newFm = {
278
+ name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
279
+ };
280
+ if (fm.description)
281
+ newFm.description = fm.description;
282
+ // resource is intentionally dropped on entity→knowledge promote (entity-only field)
283
+ if (fm.domain)
284
+ newFm.domain = fm.domain;
285
+ if (fm.related)
286
+ newFm.related = fm.related;
287
+ if (fm.tags)
288
+ newFm.tags = fm.tags;
289
+ const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
290
+ const finalBody = documentFor({ kind: 'knowledge' }).syncRelatedSection(knBody, newFm);
291
+ // An entities-only vault has no knowledge/ yet — create it like the
292
+ // create-knowledge arm does, and map any fs failure onto a HafezError
293
+ // so no raw ENOENT/ENOTDIR escapes the promote path.
294
+ try {
295
+ mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
296
+ writeFileSync(newPath, serializeFile(newFm, finalBody));
297
+ unlinkSync(filePath);
298
+ }
299
+ catch (err) {
300
+ throw new HafezError('VALIDATION_FAILED', `Failed to promote '${slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
301
+ }
302
+ modifiedFiles.push(relative(vaultPath, newPath));
303
+ // The deletion must be staged too — `git add` of a removed path stages
304
+ // the removal; omitting it leaves the vault permanently dirty.
305
+ deletedFiles.push(relative(vaultPath, filePath));
306
+ }
307
+ else {
308
+ // Same-kind: capture→entity, capture→project, entity→project
309
+ fm.type = target;
310
+ fm['last-touched'] = today();
311
+ // All body reshaping goes through Document — structural sections only,
312
+ // so quoted heading mentions inside content can no longer suppress or
313
+ // hijack the splice points.
314
+ const doc = documentFor({ kind: 'entity' });
315
+ let newBody = body;
316
+ if (target === 'entity') {
317
+ newBody = doc.renameSection(newBody, 'Notes', 'Context');
318
+ }
319
+ else {
320
+ // project
321
+ newBody = doc.renameSection(newBody, 'Notes', 'Purpose');
322
+ newBody = doc.renameSection(newBody, 'Context', 'Purpose');
323
+ newBody = doc.ensureSection(newBody, 'Goals', { after: 'Purpose' });
324
+ }
325
+ newBody = doc.prependSessionLogEntry(newBody, formatSessionLogEntry({
326
+ type: 'decision',
327
+ agent: 'system',
328
+ summary: `Promoted from ${currentType} to ${target}`,
329
+ }));
330
+ newBody = doc.syncRelatedSection(newBody, fm);
331
+ writeFileSync(filePath, serializeFile(fm, newBody));
332
+ modifiedFiles.push(relative(vaultPath, filePath));
333
+ }
334
+ return { modifiedFiles, deletedFiles };
335
+ }
336
+ // --- The arms ---
337
+ function applyUpdate(op, ctx) {
338
+ const { vaultPath } = ctx;
339
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
340
+ ctx.captureOriginal(filePath);
341
+ const kind = kindFromPath(filePath);
342
+ const parsedFile = parseFilePath(filePath);
343
+ const doc = documentForFile(parsedFile);
344
+ let fm = parsedFile.frontmatter;
345
+ let body = parsedFile.body;
346
+ const applied = applyUpdateFields(doc, fm, body, op.fields);
347
+ fm = applied.fm;
348
+ body = applied.body;
349
+ // Session log archival
350
+ const archivePath = archiveLogPath(vaultPath, op.slug);
351
+ let archived = false;
352
+ if (doc.countSessionLogEntries(body) >= 10) {
353
+ const extracted = doc.extractOldestSessionLogEntry(body);
354
+ if (extracted) {
355
+ ctx.captureOriginal(archivePath);
356
+ mkdirSync(dirname(archivePath), { recursive: true });
357
+ appendFileSync(archivePath, extracted.entry + '\n\n');
358
+ body = extracted.remaining;
359
+ archived = true;
360
+ }
361
+ }
362
+ body = doc.syncRelatedSection(body, fm);
363
+ writeFileSync(filePath, serializeFile(fm, body));
364
+ // Only the files THIS update wrote. Keying off the archive file merely
365
+ // existing swept any external hand-edit sitting in entities/archive/
366
+ // into an unrelated commit.
367
+ const files = [relative(vaultPath, filePath)];
368
+ if (archived)
369
+ files.push(relative(vaultPath, archivePath));
370
+ return {
371
+ filesWritten: files,
372
+ filesDeleted: [],
373
+ result: { op: 'update', slug: op.slug, status: 'ok' },
374
+ commitMessage: `update: ${op.slug}`,
375
+ indexRemovals: [],
376
+ indexUpserts: [{ slug: op.slug, kind }],
377
+ matchedAction: applied.matchedAction,
378
+ };
379
+ }
380
+ function applyCreateEntity(op, ctx) {
381
+ const { vaultPath } = ctx;
382
+ const slug = slugify(op.name);
383
+ const ef = op.fields;
384
+ const fm = buildEntityFrontmatter(op.name, ef, s => vaultFileExists(vaultPath, s));
385
+ let body = buildEntityBody(ef);
386
+ const filePath = entityPath(vaultPath, slug);
387
+ // Re-checked against the same cross-kind test claimNew ran (vaultFileExists
388
+ // is the real-disk half of the Spine's existsAnywhere) — not a re-litigation
389
+ // of the rule, just the race window.
390
+ if (vaultFileExists(vaultPath, slug))
391
+ appeared(slug);
392
+ ctx.captureOriginal(filePath);
393
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
394
+ body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
395
+ writeFileSync(filePath, serializeFile(fm, body));
396
+ return {
397
+ filesWritten: [relative(vaultPath, filePath)],
398
+ filesDeleted: [],
399
+ result: { op: 'create', slug, status: 'ok', created: true },
400
+ commitMessage: `create: ${op.name}`,
401
+ indexRemovals: [],
402
+ indexUpserts: [{ slug, kind: 'entity' }],
403
+ };
404
+ }
405
+ function applyCreateKnowledge(op, ctx) {
406
+ const { vaultPath } = ctx;
407
+ const slug = slugify(op.name);
408
+ const kf = (op.fields || {});
409
+ const fm = buildKnowledgeFrontmatter(op.name, kf, s => vaultFileExists(vaultPath, s));
410
+ let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
411
+ const filePath = knowledgePath(vaultPath, slug);
412
+ if (vaultFileExists(vaultPath, slug))
413
+ appeared(slug);
414
+ ctx.captureOriginal(filePath);
415
+ mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
416
+ body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
417
+ writeFileSync(filePath, serializeFile(fm, body));
418
+ return {
419
+ filesWritten: [relative(vaultPath, filePath)],
420
+ filesDeleted: [],
421
+ result: { op: 'create', slug, status: 'ok', created: true },
422
+ commitMessage: `create: ${op.name}`,
423
+ indexRemovals: [],
424
+ indexUpserts: [{ slug, kind: 'knowledge' }],
425
+ };
426
+ }
427
+ function applyCreateSession(op, ctx) {
428
+ const { vaultPath } = ctx;
429
+ const slug = slugify(op.name);
430
+ // A slug taken at validate time never reaches here: that is the
431
+ // create-session spec's skip disposition (a warning, not an error), and the
432
+ // Spine synthesizes the result without calling Apply. One taken DURING the
433
+ // validate→apply window does reach here, and overwriting someone else's
434
+ // session note is worse than the error — so the race throws even though the
435
+ // ordinary duplicate warns. Mirrors the spec's own `existsAnywhere ||
436
+ // sessionExists` test.
437
+ const sessionDir = join(vaultPath, kindDir('session'));
438
+ mkdirSync(sessionDir, { recursive: true });
439
+ const filePath = join(sessionDir, `${slug}.md`);
440
+ if (existsSync(filePath) || vaultFileExists(vaultPath, slug))
441
+ appeared(slug);
442
+ ctx.captureOriginal(filePath);
443
+ const fm = { name: op.name, created: today() };
444
+ const kf = (op.fields ?? {});
445
+ if (kf['session-date'])
446
+ fm['session-date'] = kf['session-date'];
447
+ if (kf.related?.length)
448
+ fm.related = kf.related;
449
+ // Session sections come from contracts.SESSION_SECTIONS — the same list
450
+ // Document splits on, so the written body and the structural set agree.
451
+ let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
452
+ sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
453
+ writeFileSync(filePath, serializeFile(fm, sessionBody));
454
+ // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
455
+ // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
456
+ return {
457
+ filesWritten: [relative(vaultPath, filePath)],
458
+ filesDeleted: [],
459
+ result: { op: 'create', slug, status: 'ok', created: true },
460
+ commitMessage: `create: ${op.name}`,
461
+ indexRemovals: [],
462
+ indexUpserts: [],
463
+ };
464
+ }
465
+ function applyCapture(op, ctx) {
466
+ const { vaultPath } = ctx;
467
+ const slug = slugify(op.name);
468
+ const fm = {
469
+ name: op.name,
470
+ type: 'capture',
471
+ status: 'active',
472
+ created: today(),
473
+ 'last-touched': today(),
474
+ };
475
+ const filePath = entityPath(vaultPath, slug);
476
+ if (vaultFileExists(vaultPath, slug))
477
+ appeared(slug);
478
+ ctx.captureOriginal(filePath);
479
+ mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
480
+ writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
481
+ return {
482
+ filesWritten: [relative(vaultPath, filePath)],
483
+ filesDeleted: [],
484
+ result: { op: 'capture', slug, status: 'ok', created: true },
485
+ commitMessage: `capture: ${op.name}`,
486
+ indexRemovals: [],
487
+ indexUpserts: [{ slug, kind: 'entity' }],
488
+ };
489
+ }
490
+ function applyLink(op, ctx) {
491
+ const { vaultPath } = ctx;
492
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
493
+ ctx.captureOriginal(filePath);
494
+ const parsedFile = parseFilePath(filePath);
495
+ const fm = parsedFile.frontmatter;
496
+ let body = parsedFile.body;
497
+ if (op.relation === 'parent') {
498
+ fm.parent = op.target;
499
+ }
500
+ else {
501
+ const related = fm.related || [];
502
+ if (!related.includes(op.target))
503
+ related.push(op.target);
504
+ fm.related = related;
505
+ }
506
+ body = documentForFile(parsedFile).syncRelatedSection(body, fm);
507
+ writeFileSync(filePath, serializeFile(fm, body));
508
+ return {
509
+ filesWritten: [relative(vaultPath, filePath)],
510
+ filesDeleted: [],
511
+ result: { op: 'link', slug: op.slug, status: 'ok' },
512
+ commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
513
+ indexRemovals: [],
514
+ indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
515
+ };
516
+ }
517
+ function applyUnlink(op, ctx) {
518
+ const { vaultPath } = ctx;
519
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
520
+ ctx.captureOriginal(filePath);
521
+ const parsedFile = parseFilePath(filePath);
522
+ const fm = parsedFile.frontmatter;
523
+ let body = parsedFile.body;
524
+ if (op.relation === 'parent' && fm.parent === op.target) {
525
+ delete fm.parent;
526
+ }
527
+ else if (op.relation === 'related' && fm.related) {
528
+ fm.related = fm.related.filter((r) => r !== op.target);
529
+ if (fm.related.length === 0)
530
+ delete fm.related;
531
+ }
532
+ body = documentForFile(parsedFile).syncRelatedSection(body, fm);
533
+ writeFileSync(filePath, serializeFile(fm, body));
534
+ return {
535
+ filesWritten: [relative(vaultPath, filePath)],
536
+ filesDeleted: [],
537
+ result: { op: 'unlink', slug: op.slug, status: 'ok' },
538
+ commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
539
+ indexRemovals: [],
540
+ indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
541
+ };
542
+ }
543
+ function applyPromote(op, ctx) {
544
+ const { vaultPath } = ctx;
545
+ const filePath = findVaultFile(vaultPath, op.slug) ?? vanished(op.slug);
546
+ ctx.captureOriginal(filePath);
547
+ if (op.target === 'knowledge')
548
+ ctx.captureOriginal(knowledgePath(vaultPath, op.slug));
549
+ const kind = kindFromPath(filePath);
550
+ const parsedFile = parseFilePath(filePath);
551
+ const fm = parsedFile.frontmatter;
552
+ const body = parsedFile.body;
553
+ const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
554
+ const { modifiedFiles, deletedFiles } = promoteCore(ctx, op.slug, op.target, filePath, fm, body, kind);
555
+ return {
556
+ filesWritten: modifiedFiles,
557
+ filesDeleted: deletedFiles,
558
+ result: { op: 'promote', slug: op.slug, status: 'ok' },
559
+ commitMessage: `promote: ${op.slug} ${sourceType} → ${op.target}`,
560
+ indexRemovals: op.target === 'knowledge' ? [op.slug] : [],
561
+ indexUpserts: [{ slug: op.slug, kind: op.target === 'knowledge' ? 'knowledge' : 'entity' }],
562
+ };
563
+ }
564
+ const APPLY = {
565
+ 'update': applyUpdate,
566
+ 'create-entity': applyCreateEntity,
567
+ 'create-knowledge': applyCreateKnowledge,
568
+ 'create-session': applyCreateSession,
569
+ 'capture': applyCapture,
570
+ 'link': applyLink,
571
+ 'unlink': applyUnlink,
572
+ 'promote': applyPromote,
573
+ };
574
+ /** The single apply path for every mutation. Writes vault files but never
575
+ * touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]
576
+ * under commitMessage (single ops) or one composed batch message, then runs
577
+ * the index updates. ctx.captureOriginal snapshots files for rollback before
578
+ * they change. */
579
+ export function applyOperation(op, ctx) {
580
+ const arm = APPLY[specFor(op).name];
581
+ return arm(op, ctx);
582
+ }