hafez 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -32
- package/dist/apply.d.ts +33 -0
- package/dist/apply.js +693 -0
- package/dist/batch-ops.d.ts +372 -26
- package/dist/batch-ops.js +355 -73
- package/dist/cli/args.d.ts +48 -6
- package/dist/cli/args.js +74 -9
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +28 -3
- package/dist/cli/commands.js +440 -157
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +55 -0
- package/dist/cli/index.js +215 -153
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +51 -0
- package/dist/cli/upgrade.js +351 -0
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +65 -6
- package/dist/contracts.js +148 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +40 -2
- package/dist/db.js +363 -66
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +135 -88
- package/dist/document.js +665 -379
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +50 -20
- package/dist/git.js +165 -48
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +301 -826
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +98 -64
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/schema.js +4 -4
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +78 -22
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +51 -5
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -186
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
- package/skill/SKILL.md +0 -196
package/dist/index.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { parseFilePath,
|
|
2
|
+
import { parseFilePath, slugify, kindFromPath, kindDir, isIndexedRelPath, findVaultFile, vaultFileExists, resolveFilePath, scanVaultDir, INDEXED_KINDS } from './vault.js';
|
|
3
3
|
import { createGitJournal } from './git.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
|
|
8
|
-
import { getContract, ENTITY_STATUSES, CONFIDENCE_LEVELS } from './contracts.js';
|
|
4
|
+
import { validateSessionLogEntry } from './schema.js';
|
|
5
|
+
import { validateVault } from './validate.js';
|
|
6
|
+
import { documentForFile } from './document.js';
|
|
9
7
|
import { createIndex } from './db.js';
|
|
8
|
+
import { closestSlugs } from './closest.js';
|
|
10
9
|
import { generateVaultIndex } from './knowledge-index.js';
|
|
11
10
|
import { HafezError } from './types.js';
|
|
12
|
-
import { BatchOperationSchema, specFor } from './batch-ops.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { BatchOperationSchema, specFor, slugBearingOpNames, sessionAddressingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
|
|
12
|
+
import { applyOperation, buildEntityFrontmatter, buildKnowledgeFrontmatter, validateKindFields } from './apply.js';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
14
|
+
import { join, basename } from 'path';
|
|
15
15
|
import { lock as lockVault } from 'proper-lockfile';
|
|
16
16
|
export function createHafez(config) {
|
|
17
17
|
const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
|
|
@@ -51,215 +51,28 @@ export function createHafez(config) {
|
|
|
51
51
|
catch { /* stale lock reclaimed by another process */ }
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
function
|
|
56
|
-
function
|
|
57
|
-
const ep = entityPath(slug);
|
|
58
|
-
if (existsSync(ep))
|
|
59
|
-
return ep;
|
|
60
|
-
const kp = knowledgePath(slug);
|
|
61
|
-
if (existsSync(kp))
|
|
62
|
-
return kp;
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
function exists(slug) {
|
|
66
|
-
// An invalid slug definitionally doesn't exist — reference checks (create
|
|
67
|
-
// parent/related, link targets, validate) must report "does not exist",
|
|
68
|
-
// not crash. validate() in particular exists to REPORT malformed refs.
|
|
69
|
-
try {
|
|
70
|
-
return findFile(slug) !== null;
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
function today() { return new Date().toISOString().slice(0, 10); }
|
|
77
|
-
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
|
|
78
|
-
const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
|
|
79
|
-
function validateKindFields(kind, fields) {
|
|
80
|
-
if (kind === 'knowledge') {
|
|
81
|
-
const invalid = ENTITY_ONLY_FIELDS.filter(f => fields[f] !== undefined);
|
|
82
|
-
if (invalid.length > 0) {
|
|
83
|
-
throw new HafezError('VALIDATION_FAILED', `Cannot set entity-only fields on knowledge note: ${invalid.join(', ')}`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (kind === 'entity') {
|
|
87
|
-
const invalid = KNOWLEDGE_ONLY_FIELDS.filter(f => fields[f] !== undefined);
|
|
88
|
-
if (invalid.length > 0) {
|
|
89
|
-
throw new HafezError('VALIDATION_FAILED', `Cannot set knowledge-only fields on entity: ${invalid.join(', ')}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
54
|
+
// Layout answers where a slug lives; these bind the vault path once.
|
|
55
|
+
function findFile(slug) { return findVaultFile(vaultPath, slug); }
|
|
56
|
+
function exists(slug) { return vaultFileExists(vaultPath, slug); }
|
|
93
57
|
function regenerateIndexSafe() {
|
|
94
58
|
// Runs post-commit: the vault write is already persisted/pushed, so an
|
|
95
59
|
// index.md failure must warn, never reject the operation.
|
|
96
60
|
try {
|
|
97
|
-
|
|
61
|
+
// index.md is rendered from the rows updateIndexPostCommit just
|
|
62
|
+
// refreshed, not from a second full-vault read: everything it shows
|
|
63
|
+
// (name, status, type, confidence, description, domain, body-derived
|
|
64
|
+
// summary) is already indexed. syncIfStale first so files changed
|
|
65
|
+
// outside hafez — a hand edit, a git pull — are represented too; that
|
|
66
|
+
// costs a stat per file, where the old path paid a parse per file on
|
|
67
|
+
// every single mutation.
|
|
68
|
+
const idx = getIndex();
|
|
69
|
+
idx?.syncIfStale();
|
|
70
|
+
generateVaultIndex(vaultPath, idx?.indexEntries());
|
|
98
71
|
}
|
|
99
72
|
catch (err) {
|
|
100
73
|
process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
|
|
101
74
|
}
|
|
102
75
|
}
|
|
103
|
-
// Shared by create(), batch(), and the batch validate phase so optional
|
|
104
|
-
// fields can't silently diverge between paths (see CLAUDE.md Validation
|
|
105
|
-
// Gotchas). `existsFn` is pluggable so validateBatchCore can supply a
|
|
106
|
-
// simulation-aware check (same-batch creates count as existing).
|
|
107
|
-
function buildEntityFrontmatter(name, ef, existsFn = exists) {
|
|
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
|
-
// Shared by create() and batch() like buildEntityFrontmatter — body-side
|
|
135
|
-
// optional fields (brief, actions) must behave identically in both paths.
|
|
136
|
-
function buildEntityBody(ef) {
|
|
137
|
-
let body = bodyTemplate(ef.type, { purpose: ef.purpose });
|
|
138
|
-
if (ef.brief)
|
|
139
|
-
body = setBrief(body, ef.brief);
|
|
140
|
-
if (ef.add_action)
|
|
141
|
-
body = addNextAction(body, ef.add_action);
|
|
142
|
-
for (const action of ef.add_actions ?? [])
|
|
143
|
-
body = addNextAction(body, action);
|
|
144
|
-
return body;
|
|
145
|
-
}
|
|
146
|
-
function buildKnowledgeFrontmatter(name, kf, existsFn = exists) {
|
|
147
|
-
const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
|
|
148
|
-
if (kf.domain?.length)
|
|
149
|
-
fm.domain = kf.domain;
|
|
150
|
-
if (kf.related?.length) {
|
|
151
|
-
for (const r of kf.related) {
|
|
152
|
-
if (!existsFn(r))
|
|
153
|
-
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
154
|
-
}
|
|
155
|
-
fm.related = kf.related;
|
|
156
|
-
}
|
|
157
|
-
if (kf.tags?.length)
|
|
158
|
-
fm.tags = kf.tags;
|
|
159
|
-
if (kf.description)
|
|
160
|
-
fm.description = kf.description;
|
|
161
|
-
const errors = validateKnowledgeFrontmatter(fm);
|
|
162
|
-
if (errors.length > 0)
|
|
163
|
-
throw new HafezError('VALIDATION_FAILED', 'Invalid knowledge frontmatter', errors);
|
|
164
|
-
if (kf.subtype)
|
|
165
|
-
fm.subtype = kf.subtype;
|
|
166
|
-
if (kf['session-date'])
|
|
167
|
-
fm['session-date'] = kf['session-date'];
|
|
168
|
-
return fm;
|
|
169
|
-
}
|
|
170
|
-
function applyUpdateFields(fm, body, fields) {
|
|
171
|
-
// Enum checks mirror the batch Zod shapes — without them the direct CLI
|
|
172
|
-
// path writes invalid values straight into frontmatter.
|
|
173
|
-
if (fields.status !== undefined) {
|
|
174
|
-
if (!ENTITY_STATUSES.includes(fields.status)) {
|
|
175
|
-
throw new HafezError('VALIDATION_FAILED', `Invalid status '${fields.status}'. Valid: ${ENTITY_STATUSES.join(', ')}`);
|
|
176
|
-
}
|
|
177
|
-
fm.status = fields.status;
|
|
178
|
-
}
|
|
179
|
-
if (fields.current_state !== undefined) {
|
|
180
|
-
body = setSection(body, 'Current State', fields.current_state, 'Session Log');
|
|
181
|
-
}
|
|
182
|
-
if (fields.brief !== undefined) {
|
|
183
|
-
body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
|
|
184
|
-
}
|
|
185
|
-
let matchedAction;
|
|
186
|
-
if (fields.add_action)
|
|
187
|
-
body = addNextAction(body, fields.add_action);
|
|
188
|
-
if (fields.add_actions) {
|
|
189
|
-
for (const action of fields.add_actions) {
|
|
190
|
-
body = addNextAction(body, action);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (fields.complete_action) {
|
|
194
|
-
const r = completeNextAction(body, fields.complete_action);
|
|
195
|
-
body = r.body;
|
|
196
|
-
matchedAction = r.matched;
|
|
197
|
-
}
|
|
198
|
-
if (fields.remove_action) {
|
|
199
|
-
const r = removeNextAction(body, fields.remove_action);
|
|
200
|
-
body = r.body;
|
|
201
|
-
matchedAction = r.matched;
|
|
202
|
-
}
|
|
203
|
-
if (fields.clear_actions)
|
|
204
|
-
body = clearNextActions(body);
|
|
205
|
-
if (fields.session_log) {
|
|
206
|
-
const logErrors = validateSessionLogEntry(fields.session_log);
|
|
207
|
-
if (logErrors.length > 0)
|
|
208
|
-
throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
|
|
209
|
-
body = prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
|
|
210
|
-
}
|
|
211
|
-
// Domain (shared) + knowledge-only metadata
|
|
212
|
-
if (fields.domain !== undefined) {
|
|
213
|
-
if (fields.domain.length === 0)
|
|
214
|
-
delete fm.domain;
|
|
215
|
-
else
|
|
216
|
-
fm.domain = fields.domain;
|
|
217
|
-
}
|
|
218
|
-
if (fields.description !== undefined) {
|
|
219
|
-
if (fields.description === '' || fields.description === null)
|
|
220
|
-
delete fm.description;
|
|
221
|
-
else
|
|
222
|
-
fm.description = fields.description;
|
|
223
|
-
}
|
|
224
|
-
if (fields.resource !== undefined) {
|
|
225
|
-
if (fields.resource === '' || fields.resource === null)
|
|
226
|
-
delete fm.resource;
|
|
227
|
-
else
|
|
228
|
-
fm.resource = fields.resource;
|
|
229
|
-
}
|
|
230
|
-
if (fields.confidence !== undefined) {
|
|
231
|
-
if (!CONFIDENCE_LEVELS.includes(fields.confidence)) {
|
|
232
|
-
throw new HafezError('VALIDATION_FAILED', `Invalid confidence '${fields.confidence}'. Valid: ${CONFIDENCE_LEVELS.join(', ')}`);
|
|
233
|
-
}
|
|
234
|
-
fm.confidence = fields.confidence;
|
|
235
|
-
}
|
|
236
|
-
if (fields.tags !== undefined)
|
|
237
|
-
fm.tags = fields.tags;
|
|
238
|
-
if (fields.related !== undefined) {
|
|
239
|
-
for (const r of fields.related) {
|
|
240
|
-
if (!exists(r))
|
|
241
|
-
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
242
|
-
}
|
|
243
|
-
fm.related = fields.related;
|
|
244
|
-
}
|
|
245
|
-
if (fields.synthesis !== undefined) {
|
|
246
|
-
body = setSection(body, 'Synthesis', fields.synthesis);
|
|
247
|
-
}
|
|
248
|
-
if (fields.add_evidence) {
|
|
249
|
-
const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
|
|
250
|
-
body = appendToSection(body, 'Evidence', sanitized);
|
|
251
|
-
fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
|
|
252
|
-
fm['last-reinforced'] = today();
|
|
253
|
-
// NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
|
|
254
|
-
// removed. Confidence changes are now a deliberate decision via update --confidence.
|
|
255
|
-
}
|
|
256
|
-
if (fields.add_source) {
|
|
257
|
-
body = appendToSection(body, 'Sources', fields.add_source);
|
|
258
|
-
}
|
|
259
|
-
if (fm['last-touched'] !== today())
|
|
260
|
-
fm['last-touched'] = today();
|
|
261
|
-
return { fm, body, matchedAction };
|
|
262
|
-
}
|
|
263
76
|
let _index = null;
|
|
264
77
|
let _indexInitialized = false;
|
|
265
78
|
function getIndex() {
|
|
@@ -272,21 +85,30 @@ export function createHafez(config) {
|
|
|
272
85
|
}
|
|
273
86
|
return _index;
|
|
274
87
|
}
|
|
88
|
+
// The one null-index guard: a read-only instance may have no on-disk index
|
|
89
|
+
// yet — reads then return their empty shape instead of crashing.
|
|
90
|
+
function withIndex(fn, empty) {
|
|
91
|
+
const idx = getIndex();
|
|
92
|
+
return idx ? fn(idx) : empty;
|
|
93
|
+
}
|
|
275
94
|
async function read(slug, depth = 'summary') {
|
|
276
95
|
const filePath = findFile(slug);
|
|
277
|
-
if (!filePath)
|
|
278
|
-
|
|
96
|
+
if (!filePath) {
|
|
97
|
+
// A miss carries the closest slugs as its details: the recovery the
|
|
98
|
+
// `read` miss output renders (#91).
|
|
99
|
+
const closest = withIndex(idx => { idx.syncIfStale(); return closestSlugs(slug, idx.indexEntries()); }, []);
|
|
100
|
+
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
|
|
101
|
+
}
|
|
279
102
|
const parsed = parseFilePath(filePath);
|
|
280
103
|
// findFile only resolves the indexed kinds, so the session arm is unreachable
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const body = parsed.body;
|
|
104
|
+
if (parsed.kind === 'session')
|
|
105
|
+
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
|
|
284
106
|
if (depth === 'frontmatter')
|
|
285
|
-
return {
|
|
107
|
+
return { ...parsed, body: '' };
|
|
286
108
|
if (depth === 'summary') {
|
|
287
|
-
return {
|
|
109
|
+
return { ...parsed, body: documentForFile(parsed).bodyBeforeSessionLog(parsed.body) };
|
|
288
110
|
}
|
|
289
|
-
return
|
|
111
|
+
return parsed;
|
|
290
112
|
}
|
|
291
113
|
function rollbackFiles(originals) {
|
|
292
114
|
for (const [path, content] of originals) {
|
|
@@ -313,301 +135,146 @@ export function createHafez(config) {
|
|
|
313
135
|
getIndex().upsertFromFile(u.slug, u.kind);
|
|
314
136
|
}
|
|
315
137
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const applied = applyUpdateFields(fm, body, op.fields);
|
|
333
|
-
fm = applied.fm;
|
|
334
|
-
body = applied.body;
|
|
335
|
-
// Session log archival
|
|
336
|
-
const archivePath = archiveLogPath(vaultPath, op.slug);
|
|
337
|
-
if (countSessionLogEntries(body) >= 10) {
|
|
338
|
-
const extracted = extractOldestSessionLogEntry(body);
|
|
339
|
-
if (extracted) {
|
|
340
|
-
captureOriginal(archivePath);
|
|
341
|
-
mkdirSync(dirname(archivePath), { recursive: true });
|
|
342
|
-
appendFileSync(archivePath, extracted.entry + '\n\n');
|
|
343
|
-
body = extracted.remaining;
|
|
138
|
+
async function runMutations(operations, single) {
|
|
139
|
+
if (readOnly)
|
|
140
|
+
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
141
|
+
// Declared aliases (insight → synthesis) become canonical before anything
|
|
142
|
+
// else reads the payload: validate and apply must never see different
|
|
143
|
+
// field names, or an alias validates ok and then applies as nothing.
|
|
144
|
+
operations = normalizeOperations(operations);
|
|
145
|
+
return withLock(async () => {
|
|
146
|
+
// Validate the whole payload before touching anything — all errors
|
|
147
|
+
// surface at once, localised to their op. Single ops surface their
|
|
148
|
+
// primary error class (NOT_FOUND / SLUG_EXISTS) as the thrown code.
|
|
149
|
+
const validation = validateBatchCore(operations);
|
|
150
|
+
if (!validation.valid) {
|
|
151
|
+
if (single) {
|
|
152
|
+
const opv = validation.operations[0];
|
|
153
|
+
throw new HafezError(opv.primaryCode ?? 'VALIDATION_FAILED', opv.errors.length === 1 ? opv.errors[0] : opv.errors.join('; '), opv.errors.length > 1 ? opv.errors : undefined);
|
|
344
154
|
}
|
|
155
|
+
const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
|
|
156
|
+
throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(invalidCount, operations.length), formatOpErrors(validation));
|
|
345
157
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
indexRemovals: [],
|
|
357
|
-
indexUpserts: [{ slug: op.slug, kind }],
|
|
358
|
-
matchedAction: applied.matchedAction,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
if (op.op === 'create' && op.kind === 'session') {
|
|
362
|
-
const slug = slugify(op.name);
|
|
363
|
-
if (exists(slug)) {
|
|
364
|
-
return {
|
|
365
|
-
filesWritten: [], filesDeleted: [],
|
|
366
|
-
result: { op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` },
|
|
367
|
-
commitMessage: `create: ${op.name}`, indexRemovals: [], indexUpserts: [],
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
const sessionDir = join(vaultPath, kindDir('session'));
|
|
371
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
372
|
-
const filePath = join(sessionDir, `${slug}.md`);
|
|
373
|
-
if (existsSync(filePath)) {
|
|
374
|
-
return {
|
|
375
|
-
filesWritten: [], filesDeleted: [],
|
|
376
|
-
result: { op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` },
|
|
377
|
-
commitMessage: `create: ${op.name}`, indexRemovals: [], indexUpserts: [],
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
captureOriginal(filePath);
|
|
381
|
-
const fm = { name: op.name, created: today() };
|
|
382
|
-
const kf = (op.fields ?? {});
|
|
383
|
-
if (kf['session-date'])
|
|
384
|
-
fm['session-date'] = kf['session-date'];
|
|
385
|
-
if (kf.related?.length)
|
|
386
|
-
fm.related = kf.related;
|
|
387
|
-
// Session files use sections: Summary, Entities Touched, Decisions
|
|
388
|
-
const sections = ['Summary', 'Entities Touched', 'Decisions'];
|
|
389
|
-
const lines = [];
|
|
390
|
-
for (const section of sections) {
|
|
391
|
-
lines.push(`## ${section}`);
|
|
392
|
-
if (section === 'Summary' && kf.synthesis)
|
|
393
|
-
lines.push('', kf.synthesis);
|
|
394
|
-
lines.push('');
|
|
395
|
-
}
|
|
396
|
-
let sessionBody = lines.join('\n');
|
|
397
|
-
sessionBody = syncRelatedSection(sessionBody, fm);
|
|
398
|
-
writeFileSync(filePath, serializeFile(fm, sessionBody));
|
|
399
|
-
// Sessions are not indexed (db.ts only scans entities/ and knowledge/).
|
|
400
|
-
// They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
|
|
401
|
-
return {
|
|
402
|
-
filesWritten: [relative(vaultPath, filePath)],
|
|
403
|
-
filesDeleted: [],
|
|
404
|
-
result: { op: 'create', slug, status: 'ok', created: true },
|
|
405
|
-
commitMessage: `create: ${op.name}`,
|
|
406
|
-
indexRemovals: [],
|
|
407
|
-
indexUpserts: [],
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
if (op.op === 'create') {
|
|
411
|
-
const slug = slugify(op.name);
|
|
412
|
-
if (exists(slug))
|
|
413
|
-
throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
|
|
414
|
-
if (op.kind === 'entity') {
|
|
415
|
-
const ef = op.fields;
|
|
416
|
-
const fm = buildEntityFrontmatter(op.name, ef);
|
|
417
|
-
let body = buildEntityBody(ef);
|
|
418
|
-
const filePath = entityPath(slug);
|
|
419
|
-
captureOriginal(filePath);
|
|
420
|
-
mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
|
|
421
|
-
body = syncRelatedSection(body, fm);
|
|
422
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
423
|
-
return {
|
|
424
|
-
filesWritten: [relative(vaultPath, filePath)],
|
|
425
|
-
filesDeleted: [],
|
|
426
|
-
result: { op: 'create', slug, status: 'ok', created: true },
|
|
427
|
-
commitMessage: `create: ${op.name}`,
|
|
428
|
-
indexRemovals: [],
|
|
429
|
-
indexUpserts: [{ slug, kind: 'entity' }],
|
|
430
|
-
};
|
|
158
|
+
// Apply receives the shape's output, not the raw payload, so a shape
|
|
159
|
+
// transform lands in the file instead of existing only in the
|
|
160
|
+
// validator's view.
|
|
161
|
+
operations = validation.parsedOps;
|
|
162
|
+
// Capture originals for rollback
|
|
163
|
+
const originals = new Map();
|
|
164
|
+
function captureOriginal(filePath) {
|
|
165
|
+
if (!originals.has(filePath)) {
|
|
166
|
+
originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
|
|
167
|
+
}
|
|
431
168
|
}
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
let
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
created: today(),
|
|
458
|
-
'last-touched': today(),
|
|
459
|
-
};
|
|
460
|
-
const filePath = entityPath(slug);
|
|
461
|
-
captureOriginal(filePath);
|
|
462
|
-
mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
|
|
463
|
-
writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
|
|
464
|
-
return {
|
|
465
|
-
filesWritten: [relative(vaultPath, filePath)],
|
|
466
|
-
filesDeleted: [],
|
|
467
|
-
result: { op: 'capture', slug, status: 'ok', created: true },
|
|
468
|
-
commitMessage: `capture: ${op.name}`,
|
|
469
|
-
indexRemovals: [],
|
|
470
|
-
indexUpserts: [{ slug, kind: 'entity' }],
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
if (op.op === 'link') {
|
|
474
|
-
const filePath = findFile(op.slug);
|
|
475
|
-
if (!filePath)
|
|
476
|
-
throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
|
|
477
|
-
if (!exists(op.target))
|
|
478
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
|
|
479
|
-
captureOriginal(filePath);
|
|
480
|
-
const parsedFile = parseFilePath(filePath);
|
|
481
|
-
const fm = parsedFile.frontmatter;
|
|
482
|
-
let body = parsedFile.body;
|
|
483
|
-
if (op.relation === 'parent') {
|
|
484
|
-
fm.parent = op.target;
|
|
169
|
+
const applied = [];
|
|
170
|
+
const results = [];
|
|
171
|
+
let currentOp = -1;
|
|
172
|
+
try {
|
|
173
|
+
for (const op of operations) {
|
|
174
|
+
currentOp++;
|
|
175
|
+
// A warned op is the validate phase's skip disposition (today: a
|
|
176
|
+
// duplicate session — green-lit so the batch runs on, but nothing
|
|
177
|
+
// to write). The Spine synthesizes the result rather than calling
|
|
178
|
+
// Apply, so the rule lives only in the Op Spec. `code` rides along
|
|
179
|
+
// for the single-op unwrap: that caller has nothing to return and
|
|
180
|
+
// must throw under the target-exists class, not a generic one.
|
|
181
|
+
const warning = validation.operations[currentOp].warning;
|
|
182
|
+
if (warning) {
|
|
183
|
+
results.push({
|
|
184
|
+
op: op.op, slug: validation.operations[currentOp].slug,
|
|
185
|
+
status: 'skipped', warning, code: 'SLUG_EXISTS',
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const a = applyOperation(op, { vaultPath, captureOriginal });
|
|
190
|
+
results.push(a.result);
|
|
191
|
+
if (a.result.status === 'ok')
|
|
192
|
+
applied.push(a);
|
|
193
|
+
}
|
|
485
194
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
195
|
+
catch (err) {
|
|
196
|
+
rollbackFiles(originals);
|
|
197
|
+
// The validate phase above catches semantic errors pre-execution;
|
|
198
|
+
// this localises residual execution-time failures (races, disk
|
|
199
|
+
// errors) to their op in batch mode.
|
|
200
|
+
if (!single && err instanceof HafezError && currentOp >= 0) {
|
|
201
|
+
const op = operations[currentOp];
|
|
202
|
+
const label = 'slug' in op ? op.slug : slugify(op.name);
|
|
203
|
+
throw new HafezError(err.code, opErrorLine(currentOp, op.op, label, err.message), err.details);
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
491
206
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
207
|
+
// Batch reports per-op non-'ok' results; a single-op caller unwraps
|
|
208
|
+
// applied[0], so anything that did not apply must surface as a thrown
|
|
209
|
+
// error, not a TypeError on the unwrap — under the result's own error
|
|
210
|
+
// class where it has one.
|
|
211
|
+
if (single && applied.length === 0) {
|
|
212
|
+
const failed = results.find(r => r.status !== 'ok');
|
|
213
|
+
throw new HafezError(failed?.code ?? 'VALIDATION_FAILED', failed?.error || failed?.warning || 'operation was not applied');
|
|
214
|
+
}
|
|
215
|
+
const writtenAll = [...new Set(applied.flatMap(a => a.filesWritten))];
|
|
216
|
+
const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
|
|
217
|
+
const finalize = () => {
|
|
218
|
+
updateIndexPostCommit(applied);
|
|
219
|
+
// index.md regeneration is gated: session-only writes skip it
|
|
220
|
+
if ([...writtenAll, ...deletedAll].some(isIndexedRelPath)) {
|
|
221
|
+
regenerateIndexSafe();
|
|
222
|
+
}
|
|
223
|
+
// Session creates carry no index upserts — they are not indexed
|
|
224
|
+
// entities and never notified.
|
|
225
|
+
for (const a of applied) {
|
|
226
|
+
if (a.indexUpserts.length > 0)
|
|
227
|
+
onWrite?.(a.result.slug, single ? single.action : 'batch');
|
|
228
|
+
}
|
|
501
229
|
};
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
230
|
+
if (writtenAll.length + deletedAll.length > 0) {
|
|
231
|
+
const message = single ? applied[0].commitMessage : `batch: ${operations.length} operations`;
|
|
232
|
+
try {
|
|
233
|
+
await journal.commit(writtenAll, deletedAll, message);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
237
|
+
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
238
|
+
// committed — restore the captured originals so files, journal,
|
|
239
|
+
// and index stay consistent. The adapter has already cleaned up
|
|
240
|
+
// its own partial state (staged files) before rethrowing.
|
|
241
|
+
rollbackFiles(originals);
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
// Push-stage failure: the commit exists locally ("changes saved
|
|
245
|
+
// locally") — keep it and index it, then surface the error.
|
|
246
|
+
finalize();
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
finalize();
|
|
518
250
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
return {
|
|
522
|
-
filesWritten: [relative(vaultPath, filePath)],
|
|
523
|
-
filesDeleted: [],
|
|
524
|
-
result: { op: 'unlink', slug: op.slug, status: 'ok' },
|
|
525
|
-
commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
|
|
526
|
-
indexRemovals: [],
|
|
527
|
-
indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
// promote
|
|
531
|
-
const filePath = findFile(op.slug);
|
|
532
|
-
if (!filePath)
|
|
533
|
-
throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
|
|
534
|
-
captureOriginal(filePath);
|
|
535
|
-
if (op.target === 'knowledge')
|
|
536
|
-
captureOriginal(knowledgePath(op.slug));
|
|
537
|
-
const kind = kindFromPath(filePath);
|
|
538
|
-
const parsedFile = parseFilePath(filePath);
|
|
539
|
-
const fm = parsedFile.frontmatter;
|
|
540
|
-
const body = parsedFile.body;
|
|
541
|
-
const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
542
|
-
const { modifiedFiles, deletedFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
|
|
543
|
-
return {
|
|
544
|
-
filesWritten: modifiedFiles,
|
|
545
|
-
filesDeleted: deletedFiles,
|
|
546
|
-
result: { op: 'promote', slug: op.slug, status: 'ok' },
|
|
547
|
-
commitMessage: `promote: ${op.slug} ${sourceType} → ${op.target}`,
|
|
548
|
-
indexRemovals: op.target === 'knowledge' ? [op.slug] : [],
|
|
549
|
-
indexUpserts: [{ slug: op.slug, kind: op.target === 'knowledge' ? 'knowledge' : 'entity' }],
|
|
550
|
-
};
|
|
251
|
+
return { applied, results };
|
|
252
|
+
});
|
|
551
253
|
}
|
|
552
254
|
async function create(kind, name, fields) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
: applyOperation({ op: 'create', kind, name, fields: fields });
|
|
559
|
-
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
560
|
-
updateIndexPostCommit([a]);
|
|
561
|
-
onWrite?.(a.result.slug, 'create');
|
|
562
|
-
regenerateIndexSafe();
|
|
563
|
-
return a.result.slug;
|
|
564
|
-
});
|
|
255
|
+
const op = kind === 'entity'
|
|
256
|
+
? { op: 'create', kind, name, fields: fields }
|
|
257
|
+
: { op: 'create', kind, name, fields: fields };
|
|
258
|
+
const { applied } = await runMutations([op], { action: 'create' });
|
|
259
|
+
return applied[0].result.slug;
|
|
565
260
|
}
|
|
566
261
|
async function capture(name, notes) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
return withLock(async () => {
|
|
570
|
-
const a = applyOperation({ op: 'capture', name, notes });
|
|
571
|
-
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
572
|
-
updateIndexPostCommit([a]);
|
|
573
|
-
regenerateIndexSafe();
|
|
574
|
-
onWrite?.(a.result.slug, 'capture');
|
|
575
|
-
return a.result.slug;
|
|
576
|
-
});
|
|
262
|
+
const { applied } = await runMutations([{ op: 'capture', name, notes }], { action: 'capture' });
|
|
263
|
+
return applied[0].result.slug;
|
|
577
264
|
}
|
|
578
265
|
async function update(slug, fields) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
return withLock(async () => {
|
|
582
|
-
const a = applyOperation({ op: 'update', slug, fields });
|
|
583
|
-
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
584
|
-
updateIndexPostCommit([a]);
|
|
585
|
-
regenerateIndexSafe();
|
|
586
|
-
onWrite?.(slug, 'update');
|
|
587
|
-
return { matched_action: a.matchedAction };
|
|
588
|
-
});
|
|
266
|
+
const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
|
|
267
|
+
return applied[0].updateResult ?? {};
|
|
589
268
|
}
|
|
590
269
|
async function link(slug, target, relation) {
|
|
591
|
-
|
|
592
|
-
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
593
|
-
return withLock(async () => {
|
|
594
|
-
const a = applyOperation({ op: 'link', slug, target, relation });
|
|
595
|
-
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
596
|
-
updateIndexPostCommit([a]);
|
|
597
|
-
regenerateIndexSafe();
|
|
598
|
-
onWrite?.(slug, 'link');
|
|
599
|
-
});
|
|
270
|
+
await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
|
|
600
271
|
}
|
|
601
272
|
async function unlink(slug, target, relation) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
updateIndexPostCommit([a]);
|
|
608
|
-
regenerateIndexSafe();
|
|
609
|
-
onWrite?.(slug, 'unlink');
|
|
610
|
-
});
|
|
273
|
+
await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
|
|
274
|
+
}
|
|
275
|
+
async function del(slug) {
|
|
276
|
+
const { applied } = await runMutations([{ op: 'delete', slug }], { action: 'delete' });
|
|
277
|
+
return applied[0].deletedKind;
|
|
611
278
|
}
|
|
612
279
|
// "Recent" with day-granularity dates: primary sort is the frontmatter day,
|
|
613
280
|
// ties break on git commit time (the real recency signal), then name for
|
|
@@ -645,10 +312,36 @@ export function createHafez(config) {
|
|
|
645
312
|
// slug → what it would be after earlier ops in this batch
|
|
646
313
|
const simulated = new Map();
|
|
647
314
|
const simulatedSessions = new Set();
|
|
648
|
-
|
|
315
|
+
// Deletes earlier in the batch: the slug is absent to every later op, so
|
|
316
|
+
// delete-then-create of one slug is a rewrite, not a collision.
|
|
317
|
+
const deleted = new Set();
|
|
318
|
+
const deletedSessions = new Set();
|
|
319
|
+
// slug → its parent/related after the earlier ops; files absent here
|
|
320
|
+
// carry what is on disk.
|
|
321
|
+
const simulatedLinks = new Map();
|
|
322
|
+
const existsAnywhere = (slug) => !deleted.has(slug) && (simulated.has(slug) || exists(slug));
|
|
323
|
+
const linksOf = (slug) => {
|
|
324
|
+
const sim = simulatedLinks.get(slug);
|
|
325
|
+
if (sim)
|
|
326
|
+
return sim;
|
|
327
|
+
const fp = findFile(slug);
|
|
328
|
+
if (!fp)
|
|
329
|
+
return {};
|
|
330
|
+
try {
|
|
331
|
+
const pf = parseFilePath(fp);
|
|
332
|
+
if (pf.kind === 'entity')
|
|
333
|
+
return { parent: pf.frontmatter.parent, related: pf.frontmatter.related };
|
|
334
|
+
return { related: pf.frontmatter.related };
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return {};
|
|
338
|
+
}
|
|
339
|
+
};
|
|
649
340
|
// Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
|
|
650
341
|
const ctx = {
|
|
651
342
|
resolve: (slug) => {
|
|
343
|
+
if (deleted.has(slug))
|
|
344
|
+
return null;
|
|
652
345
|
const sim = simulated.get(slug);
|
|
653
346
|
if (sim)
|
|
654
347
|
return sim;
|
|
@@ -656,8 +349,18 @@ export function createHafez(config) {
|
|
|
656
349
|
if (!fp)
|
|
657
350
|
return null;
|
|
658
351
|
const kind = kindFromPath(fp);
|
|
659
|
-
|
|
660
|
-
|
|
352
|
+
// The subtype decides which fields a knowledge note accepts (plan vs
|
|
353
|
+
// insight), so it is resolved here rather than re-read per op.
|
|
354
|
+
if (kind === 'knowledge') {
|
|
355
|
+
try {
|
|
356
|
+
const pf = parseFilePath(fp);
|
|
357
|
+
const subtype = pf.kind === 'knowledge' ? pf.frontmatter.subtype : undefined;
|
|
358
|
+
return { kind, type: 'knowledge', subtype };
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return { kind, type: 'knowledge' };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
661
364
|
try {
|
|
662
365
|
const pf = parseFilePath(fp);
|
|
663
366
|
return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
|
|
@@ -667,328 +370,120 @@ export function createHafez(config) {
|
|
|
667
370
|
}
|
|
668
371
|
},
|
|
669
372
|
existsAnywhere,
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
373
|
+
// resolveFilePath, not a bare join: delete acts on this answer, and a
|
|
374
|
+
// '../' slug must never reach a file outside sessions/.
|
|
375
|
+
sessionExists: (slug) => {
|
|
376
|
+
if (deletedSessions.has(slug))
|
|
377
|
+
return false;
|
|
378
|
+
if (simulatedSessions.has(slug))
|
|
379
|
+
return true;
|
|
380
|
+
try {
|
|
381
|
+
return existsSync(resolveFilePath(vaultPath, slug, 'session'));
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
simulate: (slug, info) => { deleted.delete(slug); simulated.set(slug, info); },
|
|
388
|
+
simulateSession: (slug) => { deletedSessions.delete(slug); simulatedSessions.add(slug); },
|
|
389
|
+
linksOf,
|
|
390
|
+
simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
|
|
391
|
+
// Read from disk, not the index: the validate phase must see the vault
|
|
392
|
+
// as `validate` will, and a stale index row would let a linked note go.
|
|
393
|
+
referrers: (slug) => {
|
|
394
|
+
const candidates = new Set(simulated.keys());
|
|
395
|
+
for (const kind of INDEXED_KINDS) {
|
|
396
|
+
for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
|
|
397
|
+
candidates.add(basename(fp, '.md'));
|
|
398
|
+
}
|
|
399
|
+
return [...candidates]
|
|
400
|
+
.filter(from => from !== slug && !deleted.has(from))
|
|
401
|
+
.filter(from => { const l = linksOf(from); return l.parent === slug || (l.related ?? []).includes(slug); })
|
|
402
|
+
.sort();
|
|
403
|
+
},
|
|
404
|
+
simulateDelete: (slug, kind) => {
|
|
405
|
+
if (kind === 'session') {
|
|
406
|
+
deletedSessions.add(slug);
|
|
407
|
+
simulatedSessions.delete(slug);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
deleted.add(slug);
|
|
411
|
+
simulated.delete(slug);
|
|
412
|
+
// A file created under this slug later in the batch starts unlinked.
|
|
413
|
+
simulatedLinks.set(slug, {});
|
|
414
|
+
},
|
|
673
415
|
validateKindFields,
|
|
674
416
|
validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
|
|
675
417
|
buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
|
|
676
418
|
buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
|
|
677
419
|
};
|
|
678
420
|
const report = [];
|
|
421
|
+
// The shape's OUTPUT, index-aligned with the report. Shapes may transform
|
|
422
|
+
// (action/evidence text is trimmed), and the semantic checks and Apply
|
|
423
|
+
// must both see what the shape emitted — validating the trimmed value but
|
|
424
|
+
// writing the raw one would store what validation never looked at.
|
|
425
|
+
const parsedOps = [];
|
|
679
426
|
for (const [index, op] of operations.entries()) {
|
|
680
427
|
// Shape and semantics in one pass: library callers get the same runtime
|
|
681
428
|
// guarantee the CLI's JSON path has always had.
|
|
682
429
|
const shape = BatchOperationSchema.safeParse(op);
|
|
683
430
|
if (!shape.success) {
|
|
431
|
+
parsedOps.push(op);
|
|
684
432
|
const raw = op;
|
|
685
433
|
const label = raw && typeof raw.slug === 'string' ? raw.slug
|
|
686
434
|
: raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
|
|
687
|
-
|
|
435
|
+
const entry = {
|
|
688
436
|
index,
|
|
689
437
|
op: typeof raw?.op === 'string' ? raw.op : '?',
|
|
690
438
|
slug: label,
|
|
691
439
|
errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
692
|
-
}
|
|
440
|
+
};
|
|
441
|
+
// A missing slug outranks field-shape problems: spec.check never runs
|
|
442
|
+
// when the shape fails, but the direct paths always surfaced NOT_FOUND
|
|
443
|
+
// first (and scripts branch on its exit code) — keep that precedence.
|
|
444
|
+
if (raw && typeof raw.slug === 'string' && raw.slug &&
|
|
445
|
+
slugBearingOpNames().includes(entry.op) &&
|
|
446
|
+
!existsAnywhere(raw.slug) &&
|
|
447
|
+
!(sessionAddressingOpNames().includes(entry.op) && ctx.sessionExists(raw.slug))) {
|
|
448
|
+
entry.errors.unshift(notFoundError(raw.slug));
|
|
449
|
+
entry.primaryCode = 'NOT_FOUND';
|
|
450
|
+
}
|
|
451
|
+
report.push(entry);
|
|
693
452
|
continue;
|
|
694
453
|
}
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
const
|
|
454
|
+
const parsed = shape.data;
|
|
455
|
+
parsedOps.push(parsed);
|
|
456
|
+
const spec = specFor(parsed);
|
|
457
|
+
const res = spec.check(parsed, ctx);
|
|
458
|
+
const entry = { index, op: parsed.op, slug: res.slug, errors: res.errors };
|
|
698
459
|
if (res.created)
|
|
699
460
|
entry.created = true;
|
|
700
461
|
if (res.warning)
|
|
701
462
|
entry.warning = res.warning;
|
|
463
|
+
if (res.primaryCode)
|
|
464
|
+
entry.primaryCode = res.primaryCode;
|
|
702
465
|
report.push(entry);
|
|
703
466
|
}
|
|
704
|
-
return { valid: report.every(o => o.errors.length === 0), operations: report };
|
|
467
|
+
return { valid: report.every(o => o.errors.length === 0), operations: report, parsedOps };
|
|
705
468
|
}
|
|
706
469
|
function formatOpErrors(report) {
|
|
707
|
-
return report.operations.flatMap(o => o.errors.map(e =>
|
|
470
|
+
return report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
|
|
708
471
|
}
|
|
709
472
|
async function batch(operations) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
return withLock(async () => {
|
|
713
|
-
// Validate the whole payload before touching anything — all errors
|
|
714
|
-
// surface at once, localised to their op.
|
|
715
|
-
const validation = validateBatchCore(operations);
|
|
716
|
-
if (!validation.valid) {
|
|
717
|
-
const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
|
|
718
|
-
throw new HafezError('VALIDATION_FAILED', `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${operations.length} applied)`, formatOpErrors(validation));
|
|
719
|
-
}
|
|
720
|
-
// Capture originals for rollback
|
|
721
|
-
const originals = new Map();
|
|
722
|
-
function captureOriginal(filePath) {
|
|
723
|
-
if (!originals.has(filePath)) {
|
|
724
|
-
originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
const appliedOps = [];
|
|
728
|
-
const results = [];
|
|
729
|
-
let currentOp = -1;
|
|
730
|
-
try {
|
|
731
|
-
for (const op of operations) {
|
|
732
|
-
currentOp++;
|
|
733
|
-
const a = applyOperation(op, captureOriginal);
|
|
734
|
-
results.push(a.result);
|
|
735
|
-
if (a.result.status === 'ok')
|
|
736
|
-
appliedOps.push(a);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
catch (err) {
|
|
740
|
-
rollbackFiles(originals);
|
|
741
|
-
// Localise: which op failed, by index and slug/name. The validate
|
|
742
|
-
// phase above catches semantic errors pre-execution; this covers
|
|
743
|
-
// residual execution-time failures (races, disk errors).
|
|
744
|
-
if (err instanceof HafezError && currentOp >= 0) {
|
|
745
|
-
const op = operations[currentOp];
|
|
746
|
-
const label = 'slug' in op ? op.slug : slugify(op.name);
|
|
747
|
-
throw new HafezError(err.code, `op[${currentOp}] (${op.op} ${label}): ${err.message}`, err.details);
|
|
748
|
-
}
|
|
749
|
-
throw err;
|
|
750
|
-
}
|
|
751
|
-
// Single journal commit for all batch operations
|
|
752
|
-
const writtenAll = [...new Set(appliedOps.flatMap(a => a.filesWritten))];
|
|
753
|
-
const deletedAll = [...new Set(appliedOps.flatMap(a => a.filesDeleted))];
|
|
754
|
-
const indexAfterBatch = () => {
|
|
755
|
-
updateIndexPostCommit(appliedOps);
|
|
756
|
-
// Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
|
|
757
|
-
const anyIndexedTouched = [...writtenAll, ...deletedAll].some(isIndexedRelPath);
|
|
758
|
-
if (anyIndexedTouched) {
|
|
759
|
-
regenerateIndexSafe();
|
|
760
|
-
}
|
|
761
|
-
// Session creates carry no index upserts — they were never notified
|
|
762
|
-
// pre-collapse either (sessions aren't indexed entities).
|
|
763
|
-
for (const a of appliedOps) {
|
|
764
|
-
if (a.indexUpserts.length > 0)
|
|
765
|
-
onWrite?.(a.result.slug, 'batch');
|
|
766
|
-
}
|
|
767
|
-
};
|
|
768
|
-
if (writtenAll.length + deletedAll.length > 0) {
|
|
769
|
-
try {
|
|
770
|
-
await journal.commit(writtenAll, deletedAll, `batch: ${operations.length} operations`);
|
|
771
|
-
}
|
|
772
|
-
catch (err) {
|
|
773
|
-
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
774
|
-
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
775
|
-
// committed — restore the captured originals so files, journal,
|
|
776
|
-
// and index stay consistent instead of leaving the batch
|
|
777
|
-
// half-applied. The adapter has already cleaned up its own
|
|
778
|
-
// partial state (staged files) before rethrowing.
|
|
779
|
-
rollbackFiles(originals);
|
|
780
|
-
throw err;
|
|
781
|
-
}
|
|
782
|
-
// Push-stage failure: the batch commit exists locally ("changes
|
|
783
|
-
// saved locally") — keep it and index it, then surface the error.
|
|
784
|
-
indexAfterBatch();
|
|
785
|
-
throw err;
|
|
786
|
-
}
|
|
787
|
-
indexAfterBatch();
|
|
788
|
-
}
|
|
789
|
-
return results;
|
|
790
|
-
});
|
|
473
|
+
const { results } = await runMutations(operations, null);
|
|
474
|
+
return results;
|
|
791
475
|
}
|
|
792
476
|
async function validate() {
|
|
793
|
-
|
|
794
|
-
broken_slugs: [],
|
|
795
|
-
orphaned_knowledge: [],
|
|
796
|
-
oversized_related: [],
|
|
797
|
-
missing_fields: [],
|
|
798
|
-
total_entities: 0,
|
|
799
|
-
total_knowledge: 0,
|
|
800
|
-
};
|
|
801
|
-
const allSlugs = new Set();
|
|
802
|
-
const knowledgeSlugs = new Set();
|
|
803
|
-
const referencedKnowledge = new Set();
|
|
804
|
-
// Scan entities
|
|
805
|
-
const entDir = join(vaultPath, kindDir('entity'));
|
|
806
|
-
{
|
|
807
|
-
const files = scanVaultDir(entDir);
|
|
808
|
-
report.total_entities = files.length;
|
|
809
|
-
for (const filePath of files) {
|
|
810
|
-
const slug = basename(filePath, '.md');
|
|
811
|
-
allSlugs.add(slug);
|
|
812
|
-
let fm;
|
|
813
|
-
try {
|
|
814
|
-
fm = parseFilePath(filePath).frontmatter;
|
|
815
|
-
}
|
|
816
|
-
catch {
|
|
817
|
-
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
818
|
-
continue;
|
|
819
|
-
}
|
|
820
|
-
const errors = validateEntityFrontmatter(fm);
|
|
821
|
-
for (const e of errors)
|
|
822
|
-
report.missing_fields.push({ slug, field: '', issue: e });
|
|
823
|
-
if (fm.parent) {
|
|
824
|
-
if (!exists(fm.parent))
|
|
825
|
-
report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
|
|
826
|
-
}
|
|
827
|
-
if (fm.related && Array.isArray(fm.related)) {
|
|
828
|
-
for (const r of fm.related) {
|
|
829
|
-
if (!exists(r))
|
|
830
|
-
report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
|
|
831
|
-
// Track referenced knowledge
|
|
832
|
-
referencedKnowledge.add(r);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
// Scan knowledge
|
|
838
|
-
const knDir = join(vaultPath, kindDir('knowledge'));
|
|
839
|
-
{
|
|
840
|
-
const files = scanVaultDir(knDir);
|
|
841
|
-
report.total_knowledge = files.length;
|
|
842
|
-
for (const filePath of files) {
|
|
843
|
-
const slug = basename(filePath, '.md');
|
|
844
|
-
allSlugs.add(slug);
|
|
845
|
-
knowledgeSlugs.add(slug);
|
|
846
|
-
let fm;
|
|
847
|
-
try {
|
|
848
|
-
fm = parseFilePath(filePath).frontmatter;
|
|
849
|
-
}
|
|
850
|
-
catch {
|
|
851
|
-
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
852
|
-
continue;
|
|
853
|
-
}
|
|
854
|
-
const errors = validateKnowledgeFrontmatter(fm);
|
|
855
|
-
for (const e of errors)
|
|
856
|
-
report.missing_fields.push({ slug, field: '', issue: e });
|
|
857
|
-
if (fm.related && Array.isArray(fm.related)) {
|
|
858
|
-
for (const r of fm.related) {
|
|
859
|
-
if (!exists(r))
|
|
860
|
-
report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
// Find orphaned knowledge: no related, and not referenced by any entity
|
|
866
|
-
for (const slug of knowledgeSlugs) {
|
|
867
|
-
let fm;
|
|
868
|
-
try {
|
|
869
|
-
fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
|
|
870
|
-
}
|
|
871
|
-
catch {
|
|
872
|
-
continue; // already reported above
|
|
873
|
-
}
|
|
874
|
-
const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
|
|
875
|
-
if (!hasRelated && !referencedKnowledge.has(slug)) {
|
|
876
|
-
report.orphaned_knowledge.push(slug);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
return report;
|
|
880
|
-
}
|
|
881
|
-
function promoteCore(slug, target, filePath, fm, body, kind) {
|
|
882
|
-
const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
883
|
-
const contract = getContract(currentType);
|
|
884
|
-
if (!contract.canPromoteTo.includes(target)) {
|
|
885
|
-
throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
|
|
886
|
-
}
|
|
887
|
-
const modifiedFiles = [];
|
|
888
|
-
const deletedFiles = [];
|
|
889
|
-
if (target === 'knowledge') {
|
|
890
|
-
// Cross-kind: move file from entities/ to knowledge/
|
|
891
|
-
const newPath = knowledgePath(slug);
|
|
892
|
-
const notesSection = findSection(body, 'Notes');
|
|
893
|
-
const notesContent = notesSection?.content ?? '';
|
|
894
|
-
const newFm = {
|
|
895
|
-
name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
|
|
896
|
-
};
|
|
897
|
-
if (fm.description)
|
|
898
|
-
newFm.description = fm.description;
|
|
899
|
-
// resource is intentionally dropped on entity→knowledge promote (entity-only field)
|
|
900
|
-
if (fm.domain)
|
|
901
|
-
newFm.domain = fm.domain;
|
|
902
|
-
if (fm.related)
|
|
903
|
-
newFm.related = fm.related;
|
|
904
|
-
if (fm.tags)
|
|
905
|
-
newFm.tags = fm.tags;
|
|
906
|
-
const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
|
|
907
|
-
const finalBody = syncRelatedSection(knBody, newFm);
|
|
908
|
-
writeFileSync(newPath, serializeFile(newFm, finalBody));
|
|
909
|
-
unlinkSync(filePath);
|
|
910
|
-
modifiedFiles.push(relative(vaultPath, newPath));
|
|
911
|
-
// The deletion must be staged too — `git add` of a removed path stages
|
|
912
|
-
// the removal; omitting it leaves the vault permanently dirty.
|
|
913
|
-
deletedFiles.push(relative(vaultPath, filePath));
|
|
914
|
-
}
|
|
915
|
-
else {
|
|
916
|
-
// Same-kind: capture→entity, capture→project, entity→project
|
|
917
|
-
fm.type = target;
|
|
918
|
-
fm['last-touched'] = today();
|
|
919
|
-
let newBody = body;
|
|
920
|
-
if (target === 'entity') {
|
|
921
|
-
newBody = newBody.replace(/^## Notes$/m, '## Context');
|
|
922
|
-
}
|
|
923
|
-
else {
|
|
924
|
-
// project
|
|
925
|
-
newBody = newBody.replace(/^## Notes$/m, '## Purpose');
|
|
926
|
-
newBody = newBody.replace(/^## Context$/m, '## Purpose');
|
|
927
|
-
if (!newBody.includes('## Goals')) {
|
|
928
|
-
const sections = newBody.split(/\n(?=## )/m);
|
|
929
|
-
const purposeIdx = sections.findIndex(s => s.startsWith('## Purpose'));
|
|
930
|
-
if (purposeIdx !== -1) {
|
|
931
|
-
sections.splice(purposeIdx + 1, 0, '## Goals\n');
|
|
932
|
-
}
|
|
933
|
-
else {
|
|
934
|
-
sections.push('## Goals\n');
|
|
935
|
-
}
|
|
936
|
-
newBody = sections.join('\n');
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
if (!newBody.includes('## Session Log'))
|
|
940
|
-
newBody += '\n## Session Log\n';
|
|
941
|
-
// Add promotion session log entry
|
|
942
|
-
const logEntry = `### ${today()} — system [decision]\nSummary: Promoted from ${currentType} to ${target}\n`;
|
|
943
|
-
const logIdx = newBody.lastIndexOf('## Session Log');
|
|
944
|
-
if (logIdx !== -1) {
|
|
945
|
-
const insertAt = logIdx + '## Session Log'.length + 1;
|
|
946
|
-
newBody = newBody.slice(0, insertAt) + '\n' + logEntry + newBody.slice(insertAt);
|
|
947
|
-
}
|
|
948
|
-
newBody = syncRelatedSection(newBody, fm);
|
|
949
|
-
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
950
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
951
|
-
}
|
|
952
|
-
return { modifiedFiles, deletedFiles };
|
|
477
|
+
return validateVault(vaultPath);
|
|
953
478
|
}
|
|
954
479
|
async function promote(slug, target) {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
|
|
963
|
-
const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
|
|
964
|
-
let a;
|
|
965
|
-
try {
|
|
966
|
-
a = applyOperation({ op: 'promote', slug, target });
|
|
967
|
-
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
968
|
-
}
|
|
969
|
-
catch (err) {
|
|
970
|
-
if (a && err instanceof HafezError && err.code === 'GIT_PUSH_FAILED') {
|
|
971
|
-
// Push-stage failure: the promote commit exists locally — keep the
|
|
972
|
-
// files and index them (same policy as batch); rolling back here
|
|
973
|
-
// would leave the worktree contradicting HEAD.
|
|
974
|
-
updateIndexPostCommit([a]);
|
|
975
|
-
regenerateIndexSafe();
|
|
976
|
-
throw err;
|
|
977
|
-
}
|
|
978
|
-
// Apply- or commit-stage failure: nothing was committed — restore.
|
|
979
|
-
if (target === 'knowledge' && originalContent) {
|
|
980
|
-
writeFileSync(filePath, originalContent);
|
|
981
|
-
// Never unlink the file we just restored (SEC-4)
|
|
982
|
-
if (knowledgeTarget && knowledgeTarget !== filePath && existsSync(knowledgeTarget))
|
|
983
|
-
unlinkSync(knowledgeTarget);
|
|
984
|
-
}
|
|
985
|
-
throw err;
|
|
986
|
-
}
|
|
987
|
-
updateIndexPostCommit([a]);
|
|
988
|
-
regenerateIndexSafe();
|
|
989
|
-
onWrite?.(slug, 'promote');
|
|
990
|
-
return slug;
|
|
991
|
-
});
|
|
480
|
+
// Rollback safety rides the Spine: applyOperation's promote arm captures
|
|
481
|
+
// both the source file and (for cross-kind moves) the knowledge target
|
|
482
|
+
// before writing, so a commit-stage failure restores the source and
|
|
483
|
+
// removes the created file (SEC-4: never unlink a restored path —
|
|
484
|
+
// rollbackFiles only unlinks paths captured as previously-absent).
|
|
485
|
+
await runMutations([{ op: 'promote', slug, target }], { action: 'promote' });
|
|
486
|
+
return slug;
|
|
992
487
|
}
|
|
993
488
|
const emptyStats = {
|
|
994
489
|
counts: { active: 0, paused: 0, done: 0 },
|
|
@@ -1000,59 +495,39 @@ export function createHafez(config) {
|
|
|
1000
495
|
read,
|
|
1001
496
|
update,
|
|
1002
497
|
promote,
|
|
1003
|
-
query: async (opts) => {
|
|
1004
|
-
|
|
1005
|
-
if (!idx)
|
|
1006
|
-
return { items: [], total: 0 };
|
|
1007
|
-
return queryEntities(idx, opts);
|
|
1008
|
-
},
|
|
1009
|
-
queryUnified: async (opts) => {
|
|
1010
|
-
const idx = getIndex();
|
|
1011
|
-
if (!idx)
|
|
1012
|
-
return { items: [], total: 0 };
|
|
1013
|
-
return queryUnified(idx, opts);
|
|
1014
|
-
},
|
|
498
|
+
query: async (opts) => withIndex(idx => idx.queryEntities(opts), { items: [], total: 0 }),
|
|
499
|
+
queryUnified: async (opts) => withIndex(idx => idx.queryUnified(opts), { items: [], total: 0 }),
|
|
1015
500
|
create,
|
|
1016
501
|
capture,
|
|
1017
502
|
link,
|
|
1018
503
|
unlink,
|
|
504
|
+
delete: del,
|
|
1019
505
|
batch,
|
|
1020
|
-
|
|
506
|
+
// Same normalization as the apply path — dry-run must judge the payload
|
|
507
|
+
// apply would actually run.
|
|
508
|
+
validateBatch: async (operations) => {
|
|
509
|
+
// parsedOps is the Spine's internal handoff, not part of the report.
|
|
510
|
+
const { valid, operations: report } = validateBatchCore(normalizeOperations(operations));
|
|
511
|
+
return { valid, operations: report };
|
|
512
|
+
},
|
|
1021
513
|
sync: async () => {
|
|
1022
514
|
if (readOnly)
|
|
1023
515
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1024
516
|
return withLock(() => journal.sync());
|
|
1025
517
|
},
|
|
1026
|
-
children: async (slug) => {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
return { items: [], total: 0 };
|
|
1030
|
-
return queryChildren(idx, slug);
|
|
1031
|
-
},
|
|
1032
|
-
related_to: async (slug, relation) => {
|
|
1033
|
-
const idx = getIndex();
|
|
1034
|
-
if (!idx)
|
|
1035
|
-
return { items: [], total: 0 };
|
|
1036
|
-
return queryRelatedTo(idx, slug, relation);
|
|
1037
|
-
},
|
|
1038
|
-
query_knowledge: async (opts) => {
|
|
1039
|
-
const idx = getIndex();
|
|
1040
|
-
if (!idx)
|
|
1041
|
-
return { items: [], total: 0 };
|
|
1042
|
-
return queryKnowledge(idx, opts);
|
|
1043
|
-
},
|
|
518
|
+
children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
|
|
519
|
+
related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
|
|
520
|
+
query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
|
|
1044
521
|
validate,
|
|
1045
|
-
search: async (query, kind) => {
|
|
1046
|
-
const idx = getIndex();
|
|
1047
|
-
if (!idx)
|
|
1048
|
-
return [];
|
|
1049
|
-
idx.syncIfStale();
|
|
1050
|
-
return idx.search(query, kind);
|
|
1051
|
-
},
|
|
522
|
+
search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
|
|
1052
523
|
rebuildIndex: async () => {
|
|
1053
524
|
if (readOnly)
|
|
1054
525
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1055
|
-
|
|
526
|
+
// A rebuild reads every vault file and rewrites the whole index, so it
|
|
527
|
+
// takes the same two-level write lock every mutation takes — otherwise it
|
|
528
|
+
// can interleave with a mutation's file writes and index a half-written
|
|
529
|
+
// vault.
|
|
530
|
+
await withLock(async () => { getIndex().rebuild(); });
|
|
1056
531
|
},
|
|
1057
532
|
stats: async () => {
|
|
1058
533
|
const idx = getIndex();
|
|
@@ -1068,5 +543,5 @@ export function createHafez(config) {
|
|
|
1068
543
|
};
|
|
1069
544
|
}
|
|
1070
545
|
export { HafezError } from './types.js';
|
|
1071
|
-
export { getBrief, getNextActions
|
|
546
|
+
export { getBrief, getNextActions } from './document.js';
|
|
1072
547
|
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|