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.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +470 -0
- package/dist/batch-ops.js +446 -0
- package/dist/cli/args.d.ts +19 -0
- package/dist/cli/args.js +53 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +24 -108
- package/dist/cli/commands.js +297 -313
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +44 -5
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +172 -172
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/cli.js +0 -0
- package/dist/contracts.d.ts +55 -6
- package/dist/contracts.js +110 -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 +318 -56
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +117 -0
- package/dist/document.js +578 -0
- package/dist/export-okf.js +48 -34
- package/dist/git.d.ts +7 -8
- package/dist/git.js +198 -63
- package/dist/index.d.ts +3 -4
- package/dist/index.js +302 -915
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +71 -13
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +106 -86
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +86 -12
- package/dist/vault.d.ts +51 -6
- package/dist/vault.js +114 -11
- package/package.json +2 -2
- package/skill/SKILL.md +51 -110
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -167
- package/dist/sections.d.ts +0 -54
- package/dist/sections.js +0 -297
package/dist/index.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { parseFilePath,
|
|
3
|
-
import {
|
|
2
|
+
import { parseFilePath, slugify, kindFromPath, kindDir, scanVaultDir, isIndexedRelPath, findVaultFile, vaultFileExists } from './vault.js';
|
|
3
|
+
import { createGitJournal } from './git.js';
|
|
4
4
|
import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
|
|
5
|
-
import {
|
|
6
|
-
import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry } from './parser.js';
|
|
7
|
-
import { setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findNextStructuralHeading, findSection } from './sections.js';
|
|
8
|
-
import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
|
|
9
|
-
import { getContract } from './contracts.js';
|
|
5
|
+
import { documentForFile } from './document.js';
|
|
10
6
|
import { createIndex } from './db.js';
|
|
11
7
|
import { generateVaultIndex } from './knowledge-index.js';
|
|
12
8
|
import { HafezError } from './types.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
9
|
+
import { BatchOperationSchema, specFor, slugBearingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
|
|
10
|
+
import { applyOperation, buildEntityFrontmatter, buildKnowledgeFrontmatter, validateKindFields } from './apply.js';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
12
|
+
import { join, basename } from 'path';
|
|
15
13
|
import { lock as lockVault } from 'proper-lockfile';
|
|
16
14
|
export function createHafez(config) {
|
|
17
15
|
const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
|
|
16
|
+
// The persistence seam (CONTEXT.md: Journal). Everything below talks to
|
|
17
|
+
// history through this port; git is just the default adapter.
|
|
18
|
+
const journal = config.persistence ?? createGitJournal(vaultPath, gitConfig);
|
|
18
19
|
// Two-level write lock: an in-process promise chain serializes calls within
|
|
19
20
|
// this instance, then a cross-process advisory lock (proper-lockfile, mkdir
|
|
20
21
|
// based — works on network/drvfs mounts) serializes against other hafez
|
|
@@ -48,244 +49,28 @@ export function createHafez(config) {
|
|
|
48
49
|
catch { /* stale lock reclaimed by another process */ }
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
-
function
|
|
53
|
-
function
|
|
54
|
-
const ep = entityPath(slug);
|
|
55
|
-
if (existsSync(ep))
|
|
56
|
-
return ep;
|
|
57
|
-
const kp = knowledgePath(slug);
|
|
58
|
-
if (existsSync(kp))
|
|
59
|
-
return kp;
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
function exists(slug) {
|
|
63
|
-
// An invalid slug definitionally doesn't exist — reference checks (create
|
|
64
|
-
// parent/related, link targets, validate) must report "does not exist",
|
|
65
|
-
// not crash. validate() in particular exists to REPORT malformed refs.
|
|
66
|
-
try {
|
|
67
|
-
return findFile(slug) !== null;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
function today() { return new Date().toISOString().slice(0, 10); }
|
|
74
|
-
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
|
|
75
|
-
const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
|
|
76
|
-
function validateKindFields(kind, fields) {
|
|
77
|
-
if (kind === 'knowledge') {
|
|
78
|
-
const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined);
|
|
79
|
-
if (invalid.length > 0) {
|
|
80
|
-
throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (kind === 'entity') {
|
|
84
|
-
const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
|
|
85
|
-
if (invalid.length > 0) {
|
|
86
|
-
throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
52
|
+
// Layout answers where a slug lives; these bind the vault path once.
|
|
53
|
+
function findFile(slug) { return findVaultFile(vaultPath, slug); }
|
|
54
|
+
function exists(slug) { return vaultFileExists(vaultPath, slug); }
|
|
90
55
|
function regenerateIndexSafe() {
|
|
91
56
|
// Runs post-commit: the vault write is already persisted/pushed, so an
|
|
92
57
|
// index.md failure must warn, never reject the operation.
|
|
93
58
|
try {
|
|
94
|
-
|
|
59
|
+
// index.md is rendered from the rows updateIndexPostCommit just
|
|
60
|
+
// refreshed, not from a second full-vault read: everything it shows
|
|
61
|
+
// (name, status, type, confidence, description, domain, body-derived
|
|
62
|
+
// summary) is already indexed. syncIfStale first so files changed
|
|
63
|
+
// outside hafez — a hand edit, a git pull — are represented too; that
|
|
64
|
+
// costs a stat per file, where the old path paid a parse per file on
|
|
65
|
+
// every single mutation.
|
|
66
|
+
const idx = getIndex();
|
|
67
|
+
idx?.syncIfStale();
|
|
68
|
+
generateVaultIndex(vaultPath, idx?.indexEntries());
|
|
95
69
|
}
|
|
96
70
|
catch (err) {
|
|
97
71
|
process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
|
|
98
72
|
}
|
|
99
73
|
}
|
|
100
|
-
// Shared by create(), batch(), and the batch validate phase so optional
|
|
101
|
-
// fields can't silently diverge between paths (see CLAUDE.md Validation
|
|
102
|
-
// Gotchas). `existsFn` is pluggable so validateBatchCore can supply a
|
|
103
|
-
// simulation-aware check (same-batch creates count as existing).
|
|
104
|
-
function buildEntityFrontmatter(name, ef, existsFn = exists) {
|
|
105
|
-
const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
|
|
106
|
-
if (ef.domain?.length)
|
|
107
|
-
fm.domain = ef.domain;
|
|
108
|
-
if (ef.parent) {
|
|
109
|
-
if (!existsFn(ef.parent))
|
|
110
|
-
throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
|
|
111
|
-
fm.parent = ef.parent;
|
|
112
|
-
}
|
|
113
|
-
if (ef.related?.length) {
|
|
114
|
-
for (const r of ef.related) {
|
|
115
|
-
if (!existsFn(r))
|
|
116
|
-
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
117
|
-
}
|
|
118
|
-
fm.related = ef.related;
|
|
119
|
-
}
|
|
120
|
-
if (ef.tags?.length)
|
|
121
|
-
fm.tags = ef.tags;
|
|
122
|
-
if (ef.description)
|
|
123
|
-
fm.description = ef.description;
|
|
124
|
-
if (ef.resource)
|
|
125
|
-
fm.resource = ef.resource;
|
|
126
|
-
const errors = validateEntityFrontmatter(fm);
|
|
127
|
-
if (errors.length > 0)
|
|
128
|
-
throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
|
|
129
|
-
return fm;
|
|
130
|
-
}
|
|
131
|
-
// Shared by create() and batch() like buildEntityFrontmatter — body-side
|
|
132
|
-
// optional fields (brief, actions) must behave identically in both paths.
|
|
133
|
-
function buildEntityBody(ef) {
|
|
134
|
-
let body = bodyTemplate(ef.type, { purpose: ef.purpose });
|
|
135
|
-
if (ef.brief)
|
|
136
|
-
body = setBrief(body, ef.brief);
|
|
137
|
-
if (ef.add_action)
|
|
138
|
-
body = addNextAction(body, ef.add_action);
|
|
139
|
-
for (const action of ef.add_actions ?? [])
|
|
140
|
-
body = addNextAction(body, action);
|
|
141
|
-
return body;
|
|
142
|
-
}
|
|
143
|
-
function buildKnowledgeFrontmatter(name, kf, existsFn = exists) {
|
|
144
|
-
const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
|
|
145
|
-
if (kf.domain?.length)
|
|
146
|
-
fm.domain = kf.domain;
|
|
147
|
-
if (kf.related?.length) {
|
|
148
|
-
for (const r of kf.related) {
|
|
149
|
-
if (!existsFn(r))
|
|
150
|
-
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
151
|
-
}
|
|
152
|
-
fm.related = kf.related;
|
|
153
|
-
}
|
|
154
|
-
if (kf.tags?.length)
|
|
155
|
-
fm.tags = kf.tags;
|
|
156
|
-
if (kf.description)
|
|
157
|
-
fm.description = kf.description;
|
|
158
|
-
const errors = validateKnowledgeFrontmatter(fm);
|
|
159
|
-
if (errors.length > 0)
|
|
160
|
-
throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
|
|
161
|
-
if (kf.subtype)
|
|
162
|
-
fm.subtype = kf.subtype;
|
|
163
|
-
if (kf['session-date'])
|
|
164
|
-
fm['session-date'] = kf['session-date'];
|
|
165
|
-
return fm;
|
|
166
|
-
}
|
|
167
|
-
function replaceSection(body, heading, content, insertBefore) {
|
|
168
|
-
const idx = body.indexOf(heading);
|
|
169
|
-
if (idx >= 0) {
|
|
170
|
-
const afterPos = idx + heading.length;
|
|
171
|
-
const nextH = findNextStructuralHeading(body, afterPos);
|
|
172
|
-
if (nextH !== -1) {
|
|
173
|
-
return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
|
|
174
|
-
}
|
|
175
|
-
return body.slice(0, afterPos) + '\n\n' + content + '\n';
|
|
176
|
-
}
|
|
177
|
-
if (insertBefore) {
|
|
178
|
-
const beforeIdx = body.indexOf(insertBefore);
|
|
179
|
-
if (beforeIdx >= 0) {
|
|
180
|
-
return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return body + '\n\n' + heading + '\n\n' + content + '\n';
|
|
184
|
-
}
|
|
185
|
-
function insertSessionLog(body, entry) {
|
|
186
|
-
const slHeader = '## Session Log';
|
|
187
|
-
const slIdx = body.indexOf(slHeader);
|
|
188
|
-
if (slIdx >= 0) {
|
|
189
|
-
const insertPos = slIdx + slHeader.length;
|
|
190
|
-
return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
|
|
191
|
-
}
|
|
192
|
-
return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
|
|
193
|
-
}
|
|
194
|
-
function appendToSection(body, heading, content) {
|
|
195
|
-
const idx = body.indexOf(heading);
|
|
196
|
-
if (idx >= 0) {
|
|
197
|
-
const afterPos = idx + heading.length;
|
|
198
|
-
const nextH = findNextStructuralHeading(body, afterPos);
|
|
199
|
-
if (nextH !== -1) {
|
|
200
|
-
return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
|
|
201
|
-
}
|
|
202
|
-
return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
|
|
203
|
-
}
|
|
204
|
-
return body + '\n\n' + heading + '\n\n' + content + '\n';
|
|
205
|
-
}
|
|
206
|
-
function applyUpdateFields(fm, body, fields) {
|
|
207
|
-
if (fields.status !== undefined)
|
|
208
|
-
fm.status = fields.status;
|
|
209
|
-
if (fields.current_state !== undefined) {
|
|
210
|
-
body = replaceSection(body, '## Current State', fields.current_state, '## Session Log');
|
|
211
|
-
}
|
|
212
|
-
if (fields.brief !== undefined) {
|
|
213
|
-
body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
|
|
214
|
-
}
|
|
215
|
-
let matchedAction;
|
|
216
|
-
if (fields.add_action)
|
|
217
|
-
body = addNextAction(body, fields.add_action);
|
|
218
|
-
if (fields.add_actions) {
|
|
219
|
-
for (const action of fields.add_actions) {
|
|
220
|
-
body = addNextAction(body, action);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
if (fields.complete_action) {
|
|
224
|
-
const r = completeNextAction(body, fields.complete_action);
|
|
225
|
-
body = r.body;
|
|
226
|
-
matchedAction = r.matched;
|
|
227
|
-
}
|
|
228
|
-
if (fields.remove_action) {
|
|
229
|
-
const r = removeNextAction(body, fields.remove_action);
|
|
230
|
-
body = r.body;
|
|
231
|
-
matchedAction = r.matched;
|
|
232
|
-
}
|
|
233
|
-
if (fields.clear_actions)
|
|
234
|
-
body = clearNextActions(body);
|
|
235
|
-
if (fields.session_log) {
|
|
236
|
-
const logErrors = validateSessionLogEntry(fields.session_log);
|
|
237
|
-
if (logErrors.length > 0)
|
|
238
|
-
throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
|
|
239
|
-
body = insertSessionLog(body, formatSessionLogEntry(fields.session_log));
|
|
240
|
-
}
|
|
241
|
-
// Domain (shared) + knowledge-only metadata
|
|
242
|
-
if (fields.domain !== undefined) {
|
|
243
|
-
if (fields.domain.length === 0)
|
|
244
|
-
delete fm.domain;
|
|
245
|
-
else
|
|
246
|
-
fm.domain = fields.domain;
|
|
247
|
-
}
|
|
248
|
-
if (fields.description !== undefined) {
|
|
249
|
-
if (fields.description === '' || fields.description === null)
|
|
250
|
-
delete fm.description;
|
|
251
|
-
else
|
|
252
|
-
fm.description = fields.description;
|
|
253
|
-
}
|
|
254
|
-
if (fields.resource !== undefined) {
|
|
255
|
-
if (fields.resource === '' || fields.resource === null)
|
|
256
|
-
delete fm.resource;
|
|
257
|
-
else
|
|
258
|
-
fm.resource = fields.resource;
|
|
259
|
-
}
|
|
260
|
-
if (fields.confidence !== undefined)
|
|
261
|
-
fm.confidence = fields.confidence;
|
|
262
|
-
if (fields.tags !== undefined)
|
|
263
|
-
fm.tags = fields.tags;
|
|
264
|
-
if (fields.related !== undefined) {
|
|
265
|
-
for (const r of fields.related) {
|
|
266
|
-
if (!exists(r))
|
|
267
|
-
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
268
|
-
}
|
|
269
|
-
fm.related = fields.related;
|
|
270
|
-
}
|
|
271
|
-
if (fields.synthesis !== undefined) {
|
|
272
|
-
body = replaceSection(body, '## Synthesis', fields.synthesis);
|
|
273
|
-
}
|
|
274
|
-
if (fields.add_evidence) {
|
|
275
|
-
const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
|
|
276
|
-
body = appendToSection(body, '## Evidence', sanitized);
|
|
277
|
-
fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
|
|
278
|
-
fm['last-reinforced'] = today();
|
|
279
|
-
// NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
|
|
280
|
-
// removed. Confidence changes are now a deliberate decision via update --confidence.
|
|
281
|
-
}
|
|
282
|
-
if (fields.add_source) {
|
|
283
|
-
body = appendToSection(body, '## Sources', fields.add_source);
|
|
284
|
-
}
|
|
285
|
-
if (fm['last-touched'] !== today())
|
|
286
|
-
fm['last-touched'] = today();
|
|
287
|
-
return { fm, body, matchedAction };
|
|
288
|
-
}
|
|
289
74
|
let _index = null;
|
|
290
75
|
let _indexInitialized = false;
|
|
291
76
|
function getIndex() {
|
|
@@ -298,171 +83,190 @@ export function createHafez(config) {
|
|
|
298
83
|
}
|
|
299
84
|
return _index;
|
|
300
85
|
}
|
|
86
|
+
// The one null-index guard: a read-only instance may have no on-disk index
|
|
87
|
+
// yet — reads then return their empty shape instead of crashing.
|
|
88
|
+
function withIndex(fn, empty) {
|
|
89
|
+
const idx = getIndex();
|
|
90
|
+
return idx ? fn(idx) : empty;
|
|
91
|
+
}
|
|
301
92
|
async function read(slug, depth = 'summary') {
|
|
302
93
|
const filePath = findFile(slug);
|
|
303
94
|
if (!filePath)
|
|
304
95
|
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
|
|
305
96
|
const parsed = parseFilePath(filePath);
|
|
97
|
+
// findFile only resolves the indexed kinds, so the session arm is unreachable
|
|
98
|
+
const kind = parsed.kind === 'session' ? 'knowledge' : parsed.kind;
|
|
306
99
|
const frontmatter = parsed.frontmatter;
|
|
307
100
|
const body = parsed.body;
|
|
308
101
|
if (depth === 'frontmatter')
|
|
309
|
-
return { frontmatter, body: '' };
|
|
102
|
+
return { kind, frontmatter, body: '' };
|
|
310
103
|
if (depth === 'summary') {
|
|
311
|
-
|
|
312
|
-
return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
|
|
104
|
+
return { kind, frontmatter, body: documentForFile(parsed).bodyBeforeSessionLog(body) };
|
|
313
105
|
}
|
|
314
|
-
return { frontmatter, body };
|
|
106
|
+
return { kind, frontmatter, body };
|
|
315
107
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (kind === 'entity') {
|
|
324
|
-
const ef = fields;
|
|
325
|
-
const fm = buildEntityFrontmatter(name, ef);
|
|
326
|
-
let body = buildEntityBody(ef);
|
|
327
|
-
const filePath = entityPath(slug);
|
|
328
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
329
|
-
body = syncRelatedSection(body, fm);
|
|
330
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
331
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
|
|
332
|
-
getIndex().upsertFromFile(slug, 'entity');
|
|
333
|
-
onWrite?.(slug, 'create');
|
|
108
|
+
function rollbackFiles(originals) {
|
|
109
|
+
for (const [path, content] of originals) {
|
|
110
|
+
if (content === null) {
|
|
111
|
+
try {
|
|
112
|
+
unlinkSync(path);
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
334
115
|
}
|
|
335
116
|
else {
|
|
336
|
-
|
|
337
|
-
const fm = buildKnowledgeFrontmatter(name, kf);
|
|
338
|
-
let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
|
|
339
|
-
const filePath = knowledgePath(slug);
|
|
340
|
-
mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
|
|
341
|
-
body = syncRelatedSection(body, fm);
|
|
342
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
343
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
|
|
344
|
-
getIndex().upsertFromFile(slug, 'knowledge');
|
|
345
|
-
onWrite?.(slug, 'create');
|
|
117
|
+
writeFileSync(path, content);
|
|
346
118
|
}
|
|
347
|
-
|
|
348
|
-
return slug;
|
|
349
|
-
});
|
|
119
|
+
}
|
|
350
120
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
created: today(),
|
|
363
|
-
'last-touched': today(),
|
|
364
|
-
};
|
|
365
|
-
const body = bodyTemplate('capture', { notes });
|
|
366
|
-
const filePath = entityPath(slug);
|
|
367
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
368
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
369
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `capture: ${name}`, gitConfig);
|
|
370
|
-
getIndex().upsertFromFile(slug, 'entity');
|
|
371
|
-
regenerateIndexSafe();
|
|
372
|
-
onWrite?.(slug, 'capture');
|
|
373
|
-
return slug;
|
|
374
|
-
});
|
|
121
|
+
// All SQLite writes happen here, strictly after the git commit succeeded.
|
|
122
|
+
// A failed commit rolls back files only — the index was never touched, so
|
|
123
|
+
// files, git, and index stay consistent (pre-collapse, promote removed its
|
|
124
|
+
// index row mid-apply and a rollback lost it).
|
|
125
|
+
function updateIndexPostCommit(applied) {
|
|
126
|
+
for (const a of applied) {
|
|
127
|
+
for (const slug of a.indexRemovals)
|
|
128
|
+
getIndex().removeItem(slug);
|
|
129
|
+
for (const u of a.indexUpserts)
|
|
130
|
+
getIndex().upsertFromFile(u.slug, u.kind);
|
|
131
|
+
}
|
|
375
132
|
}
|
|
376
|
-
async function
|
|
133
|
+
async function runMutations(operations, single) {
|
|
377
134
|
if (readOnly)
|
|
378
135
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
136
|
+
// Declared aliases (insight → synthesis) become canonical before anything
|
|
137
|
+
// else reads the payload: validate and apply must never see different
|
|
138
|
+
// field names, or an alias validates ok and then applies as nothing.
|
|
139
|
+
operations = normalizeOperations(operations);
|
|
379
140
|
return withLock(async () => {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
body = applied.body;
|
|
389
|
-
// Session log archival
|
|
390
|
-
if (countSessionLogEntries(body) >= 10) {
|
|
391
|
-
const extracted = extractOldestSessionLogEntry(body);
|
|
392
|
-
if (extracted) {
|
|
393
|
-
const archiveDir = join(vaultPath, 'entities', 'archive');
|
|
394
|
-
mkdirSync(archiveDir, { recursive: true });
|
|
395
|
-
const archivePath = join(archiveDir, `${slug}-log.md`);
|
|
396
|
-
appendFileSync(archivePath, extracted.entry + '\n\n');
|
|
397
|
-
body = extracted.remaining;
|
|
141
|
+
// Validate the whole payload before touching anything — all errors
|
|
142
|
+
// surface at once, localised to their op. Single ops surface their
|
|
143
|
+
// primary error class (NOT_FOUND / SLUG_EXISTS) as the thrown code.
|
|
144
|
+
const validation = validateBatchCore(operations);
|
|
145
|
+
if (!validation.valid) {
|
|
146
|
+
if (single) {
|
|
147
|
+
const opv = validation.operations[0];
|
|
148
|
+
throw new HafezError(opv.primaryCode ?? 'VALIDATION_FAILED', opv.errors.length === 1 ? opv.errors[0] : opv.errors.join('; '), opv.errors.length > 1 ? opv.errors : undefined);
|
|
398
149
|
}
|
|
150
|
+
const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
|
|
151
|
+
throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(invalidCount, operations.length), formatOpErrors(validation));
|
|
399
152
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
return { matched_action: applied.matchedAction };
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
async function link(slug, target, relation) {
|
|
414
|
-
if (readOnly)
|
|
415
|
-
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
416
|
-
return withLock(async () => {
|
|
417
|
-
const filePath = findFile(slug);
|
|
418
|
-
if (!filePath)
|
|
419
|
-
throw new HafezError('NOT_FOUND', `'${slug}' not found`);
|
|
420
|
-
if (!exists(target))
|
|
421
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
|
|
422
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
423
|
-
if (relation === 'parent') {
|
|
424
|
-
fm.parent = target;
|
|
153
|
+
// Apply receives the shape's output, not the raw payload, so a shape
|
|
154
|
+
// transform lands in the file instead of existing only in the
|
|
155
|
+
// validator's view.
|
|
156
|
+
operations = validation.parsedOps;
|
|
157
|
+
// Capture originals for rollback
|
|
158
|
+
const originals = new Map();
|
|
159
|
+
function captureOriginal(filePath) {
|
|
160
|
+
if (!originals.has(filePath)) {
|
|
161
|
+
originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
|
|
162
|
+
}
|
|
425
163
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
164
|
+
const applied = [];
|
|
165
|
+
const results = [];
|
|
166
|
+
let currentOp = -1;
|
|
167
|
+
try {
|
|
168
|
+
for (const op of operations) {
|
|
169
|
+
currentOp++;
|
|
170
|
+
// A warned op is the validate phase's skip disposition (today: a
|
|
171
|
+
// duplicate session — green-lit so the batch runs on, but nothing
|
|
172
|
+
// to write). The Spine synthesizes the result rather than calling
|
|
173
|
+
// Apply, so the rule lives only in the Op Spec. `code` rides along
|
|
174
|
+
// for the single-op unwrap: that caller has nothing to return and
|
|
175
|
+
// must throw under the target-exists class, not a generic one.
|
|
176
|
+
const warning = validation.operations[currentOp].warning;
|
|
177
|
+
if (warning) {
|
|
178
|
+
results.push({
|
|
179
|
+
op: op.op, slug: validation.operations[currentOp].slug,
|
|
180
|
+
status: 'skipped', warning, code: 'SLUG_EXISTS',
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const a = applyOperation(op, { vaultPath, captureOriginal });
|
|
185
|
+
results.push(a.result);
|
|
186
|
+
if (a.result.status === 'ok')
|
|
187
|
+
applied.push(a);
|
|
188
|
+
}
|
|
431
189
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
444
|
-
return withLock(async () => {
|
|
445
|
-
const filePath = findFile(slug);
|
|
446
|
-
if (!filePath)
|
|
447
|
-
throw new HafezError('NOT_FOUND', `'${slug}' not found`);
|
|
448
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
449
|
-
if (relation === 'parent' && fm.parent === target) {
|
|
450
|
-
delete fm.parent;
|
|
190
|
+
catch (err) {
|
|
191
|
+
rollbackFiles(originals);
|
|
192
|
+
// The validate phase above catches semantic errors pre-execution;
|
|
193
|
+
// this localises residual execution-time failures (races, disk
|
|
194
|
+
// errors) to their op in batch mode.
|
|
195
|
+
if (!single && err instanceof HafezError && currentOp >= 0) {
|
|
196
|
+
const op = operations[currentOp];
|
|
197
|
+
const label = 'slug' in op ? op.slug : slugify(op.name);
|
|
198
|
+
throw new HafezError(err.code, opErrorLine(currentOp, op.op, label, err.message), err.details);
|
|
199
|
+
}
|
|
200
|
+
throw err;
|
|
451
201
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
202
|
+
// Batch reports per-op non-'ok' results; a single-op caller unwraps
|
|
203
|
+
// applied[0], so anything that did not apply must surface as a thrown
|
|
204
|
+
// error, not a TypeError on the unwrap — under the result's own error
|
|
205
|
+
// class where it has one.
|
|
206
|
+
if (single && applied.length === 0) {
|
|
207
|
+
const failed = results.find(r => r.status !== 'ok');
|
|
208
|
+
throw new HafezError(failed?.code ?? 'VALIDATION_FAILED', failed?.error || failed?.warning || 'operation was not applied');
|
|
209
|
+
}
|
|
210
|
+
const writtenAll = [...new Set(applied.flatMap(a => a.filesWritten))];
|
|
211
|
+
const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
|
|
212
|
+
const finalize = () => {
|
|
213
|
+
updateIndexPostCommit(applied);
|
|
214
|
+
// index.md regeneration is gated: session-only writes skip it
|
|
215
|
+
if ([...writtenAll, ...deletedAll].some(isIndexedRelPath)) {
|
|
216
|
+
regenerateIndexSafe();
|
|
217
|
+
}
|
|
218
|
+
// Session creates carry no index upserts — they are not indexed
|
|
219
|
+
// entities and never notified.
|
|
220
|
+
for (const a of applied) {
|
|
221
|
+
if (a.indexUpserts.length > 0)
|
|
222
|
+
onWrite?.(a.result.slug, single ? single.action : 'batch');
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
if (writtenAll.length + deletedAll.length > 0) {
|
|
226
|
+
const message = single ? applied[0].commitMessage : `batch: ${operations.length} operations`;
|
|
227
|
+
try {
|
|
228
|
+
await journal.commit(writtenAll, deletedAll, message);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
232
|
+
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
233
|
+
// committed — restore the captured originals so files, journal,
|
|
234
|
+
// and index stay consistent. The adapter has already cleaned up
|
|
235
|
+
// its own partial state (staged files) before rethrowing.
|
|
236
|
+
rollbackFiles(originals);
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
// Push-stage failure: the commit exists locally ("changes saved
|
|
240
|
+
// locally") — keep it and index it, then surface the error.
|
|
241
|
+
finalize();
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
finalize();
|
|
456
245
|
}
|
|
457
|
-
|
|
458
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
459
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
|
|
460
|
-
const kind = kindFromPath(filePath);
|
|
461
|
-
getIndex().upsertFromFile(slug, kind);
|
|
462
|
-
regenerateIndexSafe();
|
|
463
|
-
onWrite?.(slug, 'unlink');
|
|
246
|
+
return { applied, results };
|
|
464
247
|
});
|
|
465
248
|
}
|
|
249
|
+
async function create(kind, name, fields) {
|
|
250
|
+
const op = kind === 'entity'
|
|
251
|
+
? { op: 'create', kind, name, fields: fields }
|
|
252
|
+
: { op: 'create', kind, name, fields: fields };
|
|
253
|
+
const { applied } = await runMutations([op], { action: 'create' });
|
|
254
|
+
return applied[0].result.slug;
|
|
255
|
+
}
|
|
256
|
+
async function capture(name, notes) {
|
|
257
|
+
const { applied } = await runMutations([{ op: 'capture', name, notes }], { action: 'capture' });
|
|
258
|
+
return applied[0].result.slug;
|
|
259
|
+
}
|
|
260
|
+
async function update(slug, fields) {
|
|
261
|
+
const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
|
|
262
|
+
return { matched_action: applied[0].matchedAction };
|
|
263
|
+
}
|
|
264
|
+
async function link(slug, target, relation) {
|
|
265
|
+
await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
|
|
266
|
+
}
|
|
267
|
+
async function unlink(slug, target, relation) {
|
|
268
|
+
await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
|
|
269
|
+
}
|
|
466
270
|
// "Recent" with day-granularity dates: primary sort is the frontmatter day,
|
|
467
271
|
// ties break on git commit time (the real recency signal), then name for
|
|
468
272
|
// files with no commit history yet. Without this the top-5 lists leak
|
|
@@ -478,8 +282,8 @@ export function createHafez(config) {
|
|
|
478
282
|
const hasTies = new Set(candidates.map(day)).size < candidates.length;
|
|
479
283
|
if (!hasTies)
|
|
480
284
|
return rows.slice(0, 5);
|
|
481
|
-
const times = await
|
|
482
|
-
const gitTime = (r) => times.get(
|
|
285
|
+
const times = await journal.fileTimes(mode);
|
|
286
|
+
const gitTime = (r) => times.get(`${kindDir('entity')}/${r.slug}.md`) ?? '';
|
|
483
287
|
candidates.sort((a, b) => {
|
|
484
288
|
const dayCmp = day(b).localeCompare(day(a));
|
|
485
289
|
if (dayCmp !== 0)
|
|
@@ -499,399 +303,100 @@ export function createHafez(config) {
|
|
|
499
303
|
// slug → what it would be after earlier ops in this batch
|
|
500
304
|
const simulated = new Map();
|
|
501
305
|
const simulatedSessions = new Set();
|
|
502
|
-
const resolve = (slug) => {
|
|
503
|
-
const sim = simulated.get(slug);
|
|
504
|
-
if (sim)
|
|
505
|
-
return sim;
|
|
506
|
-
const fp = findFile(slug);
|
|
507
|
-
if (!fp)
|
|
508
|
-
return null;
|
|
509
|
-
const kind = kindFromPath(fp);
|
|
510
|
-
if (kind === 'knowledge')
|
|
511
|
-
return { kind, type: 'knowledge' };
|
|
512
|
-
try {
|
|
513
|
-
return { kind, type: parseFilePath(fp).frontmatter.type };
|
|
514
|
-
}
|
|
515
|
-
catch {
|
|
516
|
-
return { kind, type: 'entity' };
|
|
517
|
-
}
|
|
518
|
-
};
|
|
519
306
|
const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (!target)
|
|
534
|
-
errors.push(`'${op.slug}' not found`);
|
|
535
|
-
else {
|
|
307
|
+
// Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
|
|
308
|
+
const ctx = {
|
|
309
|
+
resolve: (slug) => {
|
|
310
|
+
const sim = simulated.get(slug);
|
|
311
|
+
if (sim)
|
|
312
|
+
return sim;
|
|
313
|
+
const fp = findFile(slug);
|
|
314
|
+
if (!fp)
|
|
315
|
+
return null;
|
|
316
|
+
const kind = kindFromPath(fp);
|
|
317
|
+
// The subtype decides which fields a knowledge note accepts (plan vs
|
|
318
|
+
// insight), so it is resolved here rather than re-read per op.
|
|
319
|
+
if (kind === 'knowledge') {
|
|
536
320
|
try {
|
|
537
|
-
|
|
321
|
+
const pf = parseFilePath(fp);
|
|
322
|
+
const subtype = pf.kind === 'knowledge' ? pf.frontmatter.subtype : undefined;
|
|
323
|
+
return { kind, type: 'knowledge', subtype };
|
|
538
324
|
}
|
|
539
|
-
catch
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
if (op.fields.session_log) {
|
|
544
|
-
const logErrors = validateSessionLogEntry(op.fields.session_log);
|
|
545
|
-
if (logErrors.length > 0)
|
|
546
|
-
errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
|
|
547
|
-
}
|
|
548
|
-
if (op.fields.related) {
|
|
549
|
-
for (const r of op.fields.related) {
|
|
550
|
-
if (!existsAnywhere(r))
|
|
551
|
-
errors.push(`Related slug '${r}' does not exist`);
|
|
325
|
+
catch {
|
|
326
|
+
return { kind, type: 'knowledge' };
|
|
552
327
|
}
|
|
553
328
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if (exists(slug) || existsSync(join(vaultPath, 'sessions', `${slug}.md`)) || simulatedSessions.has(slug)) {
|
|
558
|
-
warning = `session '${slug}' already exists — op will be skipped (batch continues)`;
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
simulatedSessions.add(slug);
|
|
562
|
-
created = true;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
else if (op.op === 'create') {
|
|
566
|
-
slug = slugify(op.name);
|
|
567
|
-
if (existsAnywhere(slug))
|
|
568
|
-
errors.push(`Slug '${slug}' already exists`);
|
|
569
|
-
if (op.kind === 'entity') {
|
|
570
|
-
try {
|
|
571
|
-
buildEntityFrontmatter(op.name, op.fields, existsAnywhere);
|
|
572
|
-
}
|
|
573
|
-
catch (err) {
|
|
574
|
-
pushErr(err);
|
|
575
|
-
}
|
|
576
|
-
simulated.set(slug, { kind: 'entity', type: op.fields.type });
|
|
329
|
+
try {
|
|
330
|
+
const pf = parseFilePath(fp);
|
|
331
|
+
return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
|
|
577
332
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
buildKnowledgeFrontmatter(op.name, (op.fields || {}), existsAnywhere);
|
|
581
|
-
}
|
|
582
|
-
catch (err) {
|
|
583
|
-
pushErr(err);
|
|
584
|
-
}
|
|
585
|
-
simulated.set(slug, { kind: 'knowledge', type: 'knowledge' });
|
|
333
|
+
catch {
|
|
334
|
+
return { kind, type: 'entity' };
|
|
586
335
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
pushErr(err);
|
|
627
|
-
}
|
|
336
|
+
},
|
|
337
|
+
existsAnywhere,
|
|
338
|
+
sessionExists: (slug) => existsSync(join(vaultPath, kindDir('session'), `${slug}.md`)) || simulatedSessions.has(slug),
|
|
339
|
+
simulate: (slug, info) => { simulated.set(slug, info); },
|
|
340
|
+
simulateSession: (slug) => { simulatedSessions.add(slug); },
|
|
341
|
+
validateKindFields,
|
|
342
|
+
validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
|
|
343
|
+
buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
|
|
344
|
+
buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
|
|
345
|
+
};
|
|
346
|
+
const report = [];
|
|
347
|
+
// The shape's OUTPUT, index-aligned with the report. Shapes may transform
|
|
348
|
+
// (action/evidence text is trimmed), and the semantic checks and Apply
|
|
349
|
+
// must both see what the shape emitted — validating the trimmed value but
|
|
350
|
+
// writing the raw one would store what validation never looked at.
|
|
351
|
+
const parsedOps = [];
|
|
352
|
+
for (const [index, op] of operations.entries()) {
|
|
353
|
+
// Shape and semantics in one pass: library callers get the same runtime
|
|
354
|
+
// guarantee the CLI's JSON path has always had.
|
|
355
|
+
const shape = BatchOperationSchema.safeParse(op);
|
|
356
|
+
if (!shape.success) {
|
|
357
|
+
parsedOps.push(op);
|
|
358
|
+
const raw = op;
|
|
359
|
+
const label = raw && typeof raw.slug === 'string' ? raw.slug
|
|
360
|
+
: raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
|
|
361
|
+
const entry = {
|
|
362
|
+
index,
|
|
363
|
+
op: typeof raw?.op === 'string' ? raw.op : '?',
|
|
364
|
+
slug: label,
|
|
365
|
+
errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
366
|
+
};
|
|
367
|
+
// A missing slug outranks field-shape problems: spec.check never runs
|
|
368
|
+
// when the shape fails, but the direct paths always surfaced NOT_FOUND
|
|
369
|
+
// first (and scripts branch on its exit code) — keep that precedence.
|
|
370
|
+
if (raw && typeof raw.slug === 'string' && raw.slug &&
|
|
371
|
+
slugBearingOpNames().includes(entry.op) &&
|
|
372
|
+
!existsAnywhere(raw.slug)) {
|
|
373
|
+
entry.errors.unshift(notFoundError(raw.slug));
|
|
374
|
+
entry.primaryCode = 'NOT_FOUND';
|
|
628
375
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
376
|
+
report.push(entry);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const parsed = shape.data;
|
|
380
|
+
parsedOps.push(parsed);
|
|
381
|
+
const spec = specFor(parsed);
|
|
382
|
+
const res = spec.check(parsed, ctx);
|
|
383
|
+
const entry = { index, op: parsed.op, slug: res.slug, errors: res.errors };
|
|
384
|
+
if (res.created)
|
|
632
385
|
entry.created = true;
|
|
633
|
-
if (warning)
|
|
634
|
-
entry.warning = warning;
|
|
386
|
+
if (res.warning)
|
|
387
|
+
entry.warning = res.warning;
|
|
388
|
+
if (res.primaryCode)
|
|
389
|
+
entry.primaryCode = res.primaryCode;
|
|
635
390
|
report.push(entry);
|
|
636
391
|
}
|
|
637
|
-
return { valid: report.every(o => o.errors.length === 0), operations: report };
|
|
392
|
+
return { valid: report.every(o => o.errors.length === 0), operations: report, parsedOps };
|
|
638
393
|
}
|
|
639
394
|
function formatOpErrors(report) {
|
|
640
|
-
return report.operations.flatMap(o => o.errors.map(e =>
|
|
395
|
+
return report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
|
|
641
396
|
}
|
|
642
397
|
async function batch(operations) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
return withLock(async () => {
|
|
646
|
-
// Validate the whole payload before touching anything — all errors
|
|
647
|
-
// surface at once, localised to their op.
|
|
648
|
-
const validation = validateBatchCore(operations);
|
|
649
|
-
if (!validation.valid) {
|
|
650
|
-
const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
|
|
651
|
-
throw new HafezError('VALIDATION_FAILED', `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${operations.length} applied)`, formatOpErrors(validation));
|
|
652
|
-
}
|
|
653
|
-
// Capture originals for rollback
|
|
654
|
-
const originals = new Map();
|
|
655
|
-
function captureOriginal(filePath) {
|
|
656
|
-
if (!originals.has(filePath)) {
|
|
657
|
-
originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
const modifiedFiles = [];
|
|
661
|
-
const affectedSlugs = [];
|
|
662
|
-
const results = [];
|
|
663
|
-
let currentOp = -1;
|
|
664
|
-
try {
|
|
665
|
-
for (const op of operations) {
|
|
666
|
-
currentOp++;
|
|
667
|
-
if (op.op === 'update') {
|
|
668
|
-
const filePath = findFile(op.slug);
|
|
669
|
-
if (!filePath)
|
|
670
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
671
|
-
captureOriginal(filePath);
|
|
672
|
-
validateKindFields(kindFromPath(filePath), op.fields);
|
|
673
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
674
|
-
const applied = applyUpdateFields(fm, body, op.fields);
|
|
675
|
-
applied.body = syncRelatedSection(applied.body, applied.fm);
|
|
676
|
-
writeFileSync(filePath, serializeFile(applied.fm, applied.body));
|
|
677
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
678
|
-
affectedSlugs.push(op.slug);
|
|
679
|
-
results.push({ op: 'update', slug: op.slug, status: 'ok' });
|
|
680
|
-
}
|
|
681
|
-
else if (op.op === 'create' && op.kind === 'session') {
|
|
682
|
-
const slug = slugify(op.name);
|
|
683
|
-
if (exists(slug)) {
|
|
684
|
-
results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
|
|
685
|
-
continue;
|
|
686
|
-
}
|
|
687
|
-
const sessionDir = join(vaultPath, 'sessions');
|
|
688
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
689
|
-
const filePath = join(sessionDir, `${slug}.md`);
|
|
690
|
-
if (existsSync(filePath)) {
|
|
691
|
-
results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
|
|
692
|
-
continue;
|
|
693
|
-
}
|
|
694
|
-
captureOriginal(filePath);
|
|
695
|
-
const fm = { name: op.name, created: today() };
|
|
696
|
-
const kf = (op.fields ?? {});
|
|
697
|
-
if (kf['session-date'])
|
|
698
|
-
fm['session-date'] = kf['session-date'];
|
|
699
|
-
if (kf.related?.length)
|
|
700
|
-
fm.related = kf.related;
|
|
701
|
-
// Session files use sections: Summary, Entities Touched, Decisions
|
|
702
|
-
const sections = ['Summary', 'Entities Touched', 'Decisions'];
|
|
703
|
-
const lines = [];
|
|
704
|
-
for (const section of sections) {
|
|
705
|
-
lines.push(`## ${section}`);
|
|
706
|
-
if (section === 'Summary' && kf.synthesis)
|
|
707
|
-
lines.push('', kf.synthesis);
|
|
708
|
-
lines.push('');
|
|
709
|
-
}
|
|
710
|
-
let sessionBody = lines.join('\n');
|
|
711
|
-
sessionBody = syncRelatedSection(sessionBody, fm);
|
|
712
|
-
writeFileSync(filePath, serializeFile(fm, sessionBody));
|
|
713
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
714
|
-
// Sessions are not indexed (db.ts only scans entities/ and knowledge/).
|
|
715
|
-
// They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
|
|
716
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
717
|
-
}
|
|
718
|
-
else if (op.op === 'create') {
|
|
719
|
-
const slug = slugify(op.name);
|
|
720
|
-
if (exists(slug))
|
|
721
|
-
throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
|
|
722
|
-
if (op.kind === 'entity') {
|
|
723
|
-
const ef = op.fields;
|
|
724
|
-
const fm = buildEntityFrontmatter(op.name, ef);
|
|
725
|
-
let entBody = buildEntityBody(ef);
|
|
726
|
-
const filePath = entityPath(slug);
|
|
727
|
-
captureOriginal(filePath);
|
|
728
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
729
|
-
entBody = syncRelatedSection(entBody, fm);
|
|
730
|
-
writeFileSync(filePath, serializeFile(fm, entBody));
|
|
731
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
732
|
-
affectedSlugs.push(slug);
|
|
733
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
const kf = (op.fields || {});
|
|
737
|
-
const fm = buildKnowledgeFrontmatter(op.name, kf);
|
|
738
|
-
let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
|
|
739
|
-
const filePath = knowledgePath(slug);
|
|
740
|
-
captureOriginal(filePath);
|
|
741
|
-
mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
|
|
742
|
-
body = syncRelatedSection(body, fm);
|
|
743
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
744
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
745
|
-
affectedSlugs.push(slug);
|
|
746
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
else if (op.op === 'capture') {
|
|
750
|
-
const slug = slugify(op.name);
|
|
751
|
-
if (exists(slug))
|
|
752
|
-
throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
|
|
753
|
-
const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
|
|
754
|
-
const filePath = entityPath(slug);
|
|
755
|
-
captureOriginal(filePath);
|
|
756
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
757
|
-
writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
|
|
758
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
759
|
-
affectedSlugs.push(slug);
|
|
760
|
-
results.push({ op: 'capture', slug, status: 'ok', created: true });
|
|
761
|
-
}
|
|
762
|
-
else if (op.op === 'link') {
|
|
763
|
-
const filePath = findFile(op.slug);
|
|
764
|
-
if (!filePath)
|
|
765
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
766
|
-
if (!exists(op.target))
|
|
767
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
|
|
768
|
-
captureOriginal(filePath);
|
|
769
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
770
|
-
if (op.relation === 'parent') {
|
|
771
|
-
fm.parent = op.target;
|
|
772
|
-
}
|
|
773
|
-
else {
|
|
774
|
-
const related = fm.related || [];
|
|
775
|
-
if (!related.includes(op.target))
|
|
776
|
-
related.push(op.target);
|
|
777
|
-
fm.related = related;
|
|
778
|
-
}
|
|
779
|
-
body = syncRelatedSection(body, fm);
|
|
780
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
781
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
782
|
-
affectedSlugs.push(op.slug);
|
|
783
|
-
results.push({ op: 'link', slug: op.slug, status: 'ok' });
|
|
784
|
-
}
|
|
785
|
-
else if (op.op === 'unlink') {
|
|
786
|
-
const filePath = findFile(op.slug);
|
|
787
|
-
if (!filePath)
|
|
788
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
789
|
-
captureOriginal(filePath);
|
|
790
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
791
|
-
if (op.relation === 'parent' && fm.parent === op.target)
|
|
792
|
-
delete fm.parent;
|
|
793
|
-
else if (op.relation === 'related' && fm.related) {
|
|
794
|
-
fm.related = fm.related.filter((r) => r !== op.target);
|
|
795
|
-
if (fm.related.length === 0)
|
|
796
|
-
delete fm.related;
|
|
797
|
-
}
|
|
798
|
-
body = syncRelatedSection(body, fm);
|
|
799
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
800
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
801
|
-
affectedSlugs.push(op.slug);
|
|
802
|
-
results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
|
|
803
|
-
}
|
|
804
|
-
else if (op.op === 'promote') {
|
|
805
|
-
const filePath = findFile(op.slug);
|
|
806
|
-
if (!filePath)
|
|
807
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
808
|
-
captureOriginal(filePath);
|
|
809
|
-
if (op.target === 'knowledge') {
|
|
810
|
-
captureOriginal(knowledgePath(op.slug));
|
|
811
|
-
}
|
|
812
|
-
const kind = kindFromPath(filePath);
|
|
813
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
814
|
-
const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
|
|
815
|
-
modifiedFiles.push(...promoteFiles);
|
|
816
|
-
affectedSlugs.push(op.slug);
|
|
817
|
-
results.push({ op: 'promote', slug: op.slug, status: 'ok' });
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
catch (err) {
|
|
822
|
-
// Rollback all modified files
|
|
823
|
-
for (const [path, content] of originals) {
|
|
824
|
-
if (content === null) {
|
|
825
|
-
try {
|
|
826
|
-
unlinkSync(path);
|
|
827
|
-
}
|
|
828
|
-
catch { }
|
|
829
|
-
}
|
|
830
|
-
else {
|
|
831
|
-
writeFileSync(path, content);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
// Localise: which op failed, by index and slug/name. The validate
|
|
835
|
-
// phase above catches semantic errors pre-execution; this covers
|
|
836
|
-
// residual execution-time failures (races, disk errors).
|
|
837
|
-
if (err instanceof HafezError && currentOp >= 0) {
|
|
838
|
-
const op = operations[currentOp];
|
|
839
|
-
const label = 'slug' in op ? op.slug : slugify(op.name);
|
|
840
|
-
throw new HafezError(err.code, `op[${currentOp}] (${op.op} ${label}): ${err.message}`, err.details);
|
|
841
|
-
}
|
|
842
|
-
throw err;
|
|
843
|
-
}
|
|
844
|
-
// Single git commit for all batch operations
|
|
845
|
-
const uniqueFiles = [...new Set(modifiedFiles)];
|
|
846
|
-
const indexAfterBatch = () => {
|
|
847
|
-
for (const slug of affectedSlugs) {
|
|
848
|
-
const fp = findFile(slug);
|
|
849
|
-
if (fp) {
|
|
850
|
-
getIndex().upsertFromFile(slug, kindFromPath(fp));
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
// Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
|
|
854
|
-
const anyIndexedTouched = uniqueFiles.some(f => f.startsWith('knowledge/') || f.startsWith('entities/'));
|
|
855
|
-
if (anyIndexedTouched) {
|
|
856
|
-
regenerateIndexSafe();
|
|
857
|
-
}
|
|
858
|
-
for (const slug of affectedSlugs)
|
|
859
|
-
onWrite?.(slug, 'batch');
|
|
860
|
-
};
|
|
861
|
-
if (uniqueFiles.length > 0) {
|
|
862
|
-
try {
|
|
863
|
-
await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
|
|
864
|
-
}
|
|
865
|
-
catch (err) {
|
|
866
|
-
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
867
|
-
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
868
|
-
// committed — restore the captured originals so files, git, and
|
|
869
|
-
// index stay consistent instead of leaving the batch half-applied.
|
|
870
|
-
for (const [path, content] of originals) {
|
|
871
|
-
if (content === null) {
|
|
872
|
-
try {
|
|
873
|
-
unlinkSync(path);
|
|
874
|
-
}
|
|
875
|
-
catch { }
|
|
876
|
-
}
|
|
877
|
-
else {
|
|
878
|
-
writeFileSync(path, content);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
// Also clear anything the failed commit left staged, or the next
|
|
882
|
-
// commit would publish the rolled-back content.
|
|
883
|
-
await gitUnstage(vaultPath, uniqueFiles);
|
|
884
|
-
throw err;
|
|
885
|
-
}
|
|
886
|
-
// Push-stage failure: the batch commit exists locally ("changes
|
|
887
|
-
// saved locally") — keep it and index it, then surface the error.
|
|
888
|
-
indexAfterBatch();
|
|
889
|
-
throw err;
|
|
890
|
-
}
|
|
891
|
-
indexAfterBatch();
|
|
892
|
-
}
|
|
893
|
-
return results;
|
|
894
|
-
});
|
|
398
|
+
const { results } = await runMutations(operations, null);
|
|
399
|
+
return results;
|
|
895
400
|
}
|
|
896
401
|
async function validate() {
|
|
897
402
|
const report = {
|
|
@@ -906,16 +411,16 @@ export function createHafez(config) {
|
|
|
906
411
|
const knowledgeSlugs = new Set();
|
|
907
412
|
const referencedKnowledge = new Set();
|
|
908
413
|
// Scan entities
|
|
909
|
-
const entDir = join(vaultPath, '
|
|
910
|
-
|
|
911
|
-
const files =
|
|
414
|
+
const entDir = join(vaultPath, kindDir('entity'));
|
|
415
|
+
{
|
|
416
|
+
const files = scanVaultDir(entDir);
|
|
912
417
|
report.total_entities = files.length;
|
|
913
|
-
for (const
|
|
914
|
-
const slug = basename(
|
|
418
|
+
for (const filePath of files) {
|
|
419
|
+
const slug = basename(filePath, '.md');
|
|
915
420
|
allSlugs.add(slug);
|
|
916
421
|
let fm;
|
|
917
422
|
try {
|
|
918
|
-
fm = parseFilePath(
|
|
423
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
919
424
|
}
|
|
920
425
|
catch {
|
|
921
426
|
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
@@ -939,17 +444,17 @@ export function createHafez(config) {
|
|
|
939
444
|
}
|
|
940
445
|
}
|
|
941
446
|
// Scan knowledge
|
|
942
|
-
const knDir = join(vaultPath, 'knowledge');
|
|
943
|
-
|
|
944
|
-
const files =
|
|
447
|
+
const knDir = join(vaultPath, kindDir('knowledge'));
|
|
448
|
+
{
|
|
449
|
+
const files = scanVaultDir(knDir);
|
|
945
450
|
report.total_knowledge = files.length;
|
|
946
|
-
for (const
|
|
947
|
-
const slug = basename(
|
|
451
|
+
for (const filePath of files) {
|
|
452
|
+
const slug = basename(filePath, '.md');
|
|
948
453
|
allSlugs.add(slug);
|
|
949
454
|
knowledgeSlugs.add(slug);
|
|
950
455
|
let fm;
|
|
951
456
|
try {
|
|
952
|
-
fm = parseFilePath(
|
|
457
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
953
458
|
}
|
|
954
459
|
catch {
|
|
955
460
|
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
@@ -982,110 +487,14 @@ export function createHafez(config) {
|
|
|
982
487
|
}
|
|
983
488
|
return report;
|
|
984
489
|
}
|
|
985
|
-
function promoteCore(slug, target, filePath, fm, body, kind) {
|
|
986
|
-
const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
987
|
-
const contract = getContract(currentType);
|
|
988
|
-
if (!contract.canPromoteTo.includes(target)) {
|
|
989
|
-
throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
|
|
990
|
-
}
|
|
991
|
-
const modifiedFiles = [];
|
|
992
|
-
if (target === 'knowledge') {
|
|
993
|
-
// Cross-kind: move file from entities/ to knowledge/
|
|
994
|
-
const newPath = knowledgePath(slug);
|
|
995
|
-
const notesSection = findSection(body, 'Notes');
|
|
996
|
-
const notesContent = notesSection?.content ?? '';
|
|
997
|
-
const newFm = {
|
|
998
|
-
name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
|
|
999
|
-
};
|
|
1000
|
-
if (fm.description)
|
|
1001
|
-
newFm.description = fm.description;
|
|
1002
|
-
// resource is intentionally dropped on entity→knowledge promote (entity-only field)
|
|
1003
|
-
if (fm.domain)
|
|
1004
|
-
newFm.domain = fm.domain;
|
|
1005
|
-
if (fm.related)
|
|
1006
|
-
newFm.related = fm.related;
|
|
1007
|
-
if (fm.tags)
|
|
1008
|
-
newFm.tags = fm.tags;
|
|
1009
|
-
const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
|
|
1010
|
-
const finalBody = syncRelatedSection(knBody, newFm);
|
|
1011
|
-
writeFileSync(newPath, serializeFile(newFm, finalBody));
|
|
1012
|
-
unlinkSync(filePath);
|
|
1013
|
-
modifiedFiles.push(relative(vaultPath, newPath));
|
|
1014
|
-
// The deletion must be staged too — `git add` of a removed path stages
|
|
1015
|
-
// the removal; omitting it leaves the vault permanently dirty.
|
|
1016
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
1017
|
-
getIndex().removeItem(slug);
|
|
1018
|
-
}
|
|
1019
|
-
else {
|
|
1020
|
-
// Same-kind: capture→entity, capture→project, entity→project
|
|
1021
|
-
fm.type = target;
|
|
1022
|
-
fm['last-touched'] = today();
|
|
1023
|
-
let newBody = body;
|
|
1024
|
-
if (target === 'entity') {
|
|
1025
|
-
newBody = newBody.replace(/^## Notes$/m, '## Context');
|
|
1026
|
-
}
|
|
1027
|
-
else {
|
|
1028
|
-
// project
|
|
1029
|
-
newBody = newBody.replace(/^## Notes$/m, '## Purpose');
|
|
1030
|
-
newBody = newBody.replace(/^## Context$/m, '## Purpose');
|
|
1031
|
-
if (!newBody.includes('## Goals')) {
|
|
1032
|
-
const sections = newBody.split(/\n(?=## )/m);
|
|
1033
|
-
const purposeIdx = sections.findIndex(s => s.startsWith('## Purpose'));
|
|
1034
|
-
if (purposeIdx !== -1) {
|
|
1035
|
-
sections.splice(purposeIdx + 1, 0, '## Goals\n');
|
|
1036
|
-
}
|
|
1037
|
-
else {
|
|
1038
|
-
sections.push('## Goals\n');
|
|
1039
|
-
}
|
|
1040
|
-
newBody = sections.join('\n');
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
if (!newBody.includes('## Session Log'))
|
|
1044
|
-
newBody += '\n## Session Log\n';
|
|
1045
|
-
// Add promotion session log entry
|
|
1046
|
-
const logEntry = `### ${today()} — system [decision]\nSummary: Promoted from ${currentType} to ${target}\n`;
|
|
1047
|
-
const logIdx = newBody.lastIndexOf('## Session Log');
|
|
1048
|
-
if (logIdx !== -1) {
|
|
1049
|
-
const insertAt = logIdx + '## Session Log'.length + 1;
|
|
1050
|
-
newBody = newBody.slice(0, insertAt) + '\n' + logEntry + newBody.slice(insertAt);
|
|
1051
|
-
}
|
|
1052
|
-
newBody = syncRelatedSection(newBody, fm);
|
|
1053
|
-
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
1054
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
1055
|
-
}
|
|
1056
|
-
return { modifiedFiles };
|
|
1057
|
-
}
|
|
1058
490
|
async function promote(slug, target) {
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
// Capture original content for rollback before parsing (avoids double read)
|
|
1067
|
-
const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
|
|
1068
|
-
const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
|
|
1069
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
1070
|
-
const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
1071
|
-
try {
|
|
1072
|
-
const { modifiedFiles } = promoteCore(slug, target, filePath, fm, body, kind);
|
|
1073
|
-
await gitCommitAndPush(vaultPath, modifiedFiles, `promote: ${slug} ${sourceType} → ${target}`, gitConfig);
|
|
1074
|
-
getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
|
|
1075
|
-
regenerateIndexSafe();
|
|
1076
|
-
onWrite?.(slug, 'promote');
|
|
1077
|
-
}
|
|
1078
|
-
catch (err) {
|
|
1079
|
-
if (target === 'knowledge' && originalContent) {
|
|
1080
|
-
writeFileSync(filePath, originalContent);
|
|
1081
|
-
// Never unlink the file we just restored (SEC-4)
|
|
1082
|
-
if (knowledgeTarget && knowledgeTarget !== filePath && existsSync(knowledgeTarget))
|
|
1083
|
-
unlinkSync(knowledgeTarget);
|
|
1084
|
-
}
|
|
1085
|
-
throw err;
|
|
1086
|
-
}
|
|
1087
|
-
return slug;
|
|
1088
|
-
});
|
|
491
|
+
// Rollback safety rides the Spine: applyOperation's promote arm captures
|
|
492
|
+
// both the source file and (for cross-kind moves) the knowledge target
|
|
493
|
+
// before writing, so a commit-stage failure restores the source and
|
|
494
|
+
// removes the created file (SEC-4: never unlink a restored path —
|
|
495
|
+
// rollbackFiles only unlinks paths captured as previously-absent).
|
|
496
|
+
await runMutations([{ op: 'promote', slug, target }], { action: 'promote' });
|
|
497
|
+
return slug;
|
|
1089
498
|
}
|
|
1090
499
|
const emptyStats = {
|
|
1091
500
|
counts: { active: 0, paused: 0, done: 0 },
|
|
@@ -1097,59 +506,38 @@ export function createHafez(config) {
|
|
|
1097
506
|
read,
|
|
1098
507
|
update,
|
|
1099
508
|
promote,
|
|
1100
|
-
query: async (opts) => {
|
|
1101
|
-
|
|
1102
|
-
if (!idx)
|
|
1103
|
-
return { items: [], total: 0 };
|
|
1104
|
-
return queryEntities(idx, opts);
|
|
1105
|
-
},
|
|
1106
|
-
queryUnified: async (opts) => {
|
|
1107
|
-
const idx = getIndex();
|
|
1108
|
-
if (!idx)
|
|
1109
|
-
return { items: [], total: 0 };
|
|
1110
|
-
return queryUnified(idx, opts);
|
|
1111
|
-
},
|
|
509
|
+
query: async (opts) => withIndex(idx => idx.queryEntities(opts), { items: [], total: 0 }),
|
|
510
|
+
queryUnified: async (opts) => withIndex(idx => idx.queryUnified(opts), { items: [], total: 0 }),
|
|
1112
511
|
create,
|
|
1113
512
|
capture,
|
|
1114
513
|
link,
|
|
1115
514
|
unlink,
|
|
1116
515
|
batch,
|
|
1117
|
-
|
|
516
|
+
// Same normalization as the apply path — dry-run must judge the payload
|
|
517
|
+
// apply would actually run.
|
|
518
|
+
validateBatch: async (operations) => {
|
|
519
|
+
// parsedOps is the Spine's internal handoff, not part of the report.
|
|
520
|
+
const { valid, operations: report } = validateBatchCore(normalizeOperations(operations));
|
|
521
|
+
return { valid, operations: report };
|
|
522
|
+
},
|
|
1118
523
|
sync: async () => {
|
|
1119
524
|
if (readOnly)
|
|
1120
525
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1121
|
-
return withLock(() =>
|
|
1122
|
-
},
|
|
1123
|
-
children: async (slug) => {
|
|
1124
|
-
const idx = getIndex();
|
|
1125
|
-
if (!idx)
|
|
1126
|
-
return { items: [], total: 0 };
|
|
1127
|
-
return queryChildren(idx, slug);
|
|
1128
|
-
},
|
|
1129
|
-
related_to: async (slug, relation) => {
|
|
1130
|
-
const idx = getIndex();
|
|
1131
|
-
if (!idx)
|
|
1132
|
-
return { items: [], total: 0 };
|
|
1133
|
-
return queryRelatedTo(idx, slug, relation);
|
|
1134
|
-
},
|
|
1135
|
-
query_knowledge: async (opts) => {
|
|
1136
|
-
const idx = getIndex();
|
|
1137
|
-
if (!idx)
|
|
1138
|
-
return { items: [], total: 0 };
|
|
1139
|
-
return queryKnowledge(idx, opts);
|
|
526
|
+
return withLock(() => journal.sync());
|
|
1140
527
|
},
|
|
528
|
+
children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
|
|
529
|
+
related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
|
|
530
|
+
query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
|
|
1141
531
|
validate,
|
|
1142
|
-
search: async (query, kind) => {
|
|
1143
|
-
const idx = getIndex();
|
|
1144
|
-
if (!idx)
|
|
1145
|
-
return [];
|
|
1146
|
-
idx.syncIfStale();
|
|
1147
|
-
return idx.search(query, kind);
|
|
1148
|
-
},
|
|
532
|
+
search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
|
|
1149
533
|
rebuildIndex: async () => {
|
|
1150
534
|
if (readOnly)
|
|
1151
535
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1152
|
-
|
|
536
|
+
// A rebuild reads every vault file and rewrites the whole index, so it
|
|
537
|
+
// takes the same two-level write lock every mutation takes — otherwise it
|
|
538
|
+
// can interleave with a mutation's file writes and index a half-written
|
|
539
|
+
// vault.
|
|
540
|
+
await withLock(async () => { getIndex().rebuild(); });
|
|
1153
541
|
},
|
|
1154
542
|
stats: async () => {
|
|
1155
543
|
const idx = getIndex();
|
|
@@ -1161,10 +549,9 @@ export function createHafez(config) {
|
|
|
1161
549
|
s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
|
|
1162
550
|
return s;
|
|
1163
551
|
},
|
|
1164
|
-
changelog: (since) =>
|
|
552
|
+
changelog: (since) => journal.changelog(since),
|
|
1165
553
|
};
|
|
1166
554
|
}
|
|
1167
555
|
export { HafezError } from './types.js';
|
|
1168
|
-
export { getBrief, getNextActions
|
|
1169
|
-
export { parseSessionLogHeading, parseSessionLog } from './
|
|
1170
|
-
export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
|
|
556
|
+
export { getBrief, getNextActions } from './document.js';
|
|
557
|
+
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|