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.
- package/README.md +83 -32
- package/dist/apply.d.ts +33 -0
- package/dist/apply.js +693 -0
- package/dist/batch-ops.d.ts +372 -26
- package/dist/batch-ops.js +355 -73
- package/dist/cli/args.d.ts +48 -6
- package/dist/cli/args.js +74 -9
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +28 -3
- package/dist/cli/commands.js +440 -157
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +55 -0
- package/dist/cli/index.js +215 -153
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +51 -0
- package/dist/cli/upgrade.js +351 -0
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +65 -6
- package/dist/contracts.js +148 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +40 -2
- package/dist/db.js +363 -66
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +135 -88
- package/dist/document.js +665 -379
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +50 -20
- package/dist/git.js +165 -48
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +301 -826
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +98 -64
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/schema.js +4 -4
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +78 -22
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +51 -5
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -186
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
- 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
|
+
}
|