hafez 1.3.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.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +296 -13
- package/dist/batch-ops.js +186 -62
- package/dist/cli/args.d.ts +5 -1
- package/dist/cli/args.js +6 -2
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +197 -115
- package/dist/cli/help-agent.js +24 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +150 -130
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/contracts.d.ts +49 -6
- package/dist/contracts.js +104 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +309 -33
- package/dist/document.d.ts +97 -88
- package/dist/document.js +512 -382
- package/dist/export-okf.js +41 -15
- package/dist/git.js +165 -48
- package/dist/index.d.ts +2 -2
- package/dist/index.js +212 -727
- 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 +96 -64
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +38 -11
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +50 -4
- package/package.json +1 -1
- package/skill/SKILL.md +51 -110
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
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
|
+
}
|