hafez 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +296 -13
- package/dist/batch-ops.js +186 -62
- package/dist/cli/args.d.ts +5 -1
- package/dist/cli/args.js +6 -2
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +197 -115
- package/dist/cli/help-agent.js +24 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +150 -130
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/contracts.d.ts +49 -6
- package/dist/contracts.js +104 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +309 -33
- package/dist/document.d.ts +97 -88
- package/dist/document.js +512 -382
- package/dist/export-okf.js +41 -15
- package/dist/git.js +165 -48
- package/dist/index.d.ts +2 -2
- package/dist/index.js +212 -727
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +96 -64
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +38 -11
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +50 -4
- package/package.json +1 -1
- package/skill/SKILL.md +51 -110
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
package/dist/index.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { parseFilePath,
|
|
2
|
+
import { parseFilePath, slugify, kindFromPath, kindDir, scanVaultDir, isIndexedRelPath, findVaultFile, vaultFileExists } from './vault.js';
|
|
3
3
|
import { createGitJournal } from './git.js';
|
|
4
4
|
import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
|
|
5
|
-
import {
|
|
6
|
-
import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry, setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findSection, setSection, appendToSection, prependSessionLogEntry, bodyBeforeSessionLog } from './document.js';
|
|
7
|
-
import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
|
|
8
|
-
import { getContract, ENTITY_STATUSES, CONFIDENCE_LEVELS } from './contracts.js';
|
|
5
|
+
import { documentForFile } from './document.js';
|
|
9
6
|
import { createIndex } from './db.js';
|
|
10
7
|
import { generateVaultIndex } from './knowledge-index.js';
|
|
11
8
|
import { HafezError } from './types.js';
|
|
12
|
-
import { BatchOperationSchema, specFor } from './batch-ops.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;
|
|
@@ -51,215 +49,28 @@ export function createHafez(config) {
|
|
|
51
49
|
catch { /* stale lock reclaimed by another process */ }
|
|
52
50
|
}
|
|
53
51
|
}
|
|
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
|
-
}
|
|
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); }
|
|
93
55
|
function regenerateIndexSafe() {
|
|
94
56
|
// Runs post-commit: the vault write is already persisted/pushed, so an
|
|
95
57
|
// index.md failure must warn, never reject the operation.
|
|
96
58
|
try {
|
|
97
|
-
|
|
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());
|
|
98
69
|
}
|
|
99
70
|
catch (err) {
|
|
100
71
|
process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
|
|
101
72
|
}
|
|
102
73
|
}
|
|
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
74
|
let _index = null;
|
|
264
75
|
let _indexInitialized = false;
|
|
265
76
|
function getIndex() {
|
|
@@ -272,6 +83,12 @@ export function createHafez(config) {
|
|
|
272
83
|
}
|
|
273
84
|
return _index;
|
|
274
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
|
+
}
|
|
275
92
|
async function read(slug, depth = 'summary') {
|
|
276
93
|
const filePath = findFile(slug);
|
|
277
94
|
if (!filePath)
|
|
@@ -284,7 +101,7 @@ export function createHafez(config) {
|
|
|
284
101
|
if (depth === 'frontmatter')
|
|
285
102
|
return { kind, frontmatter, body: '' };
|
|
286
103
|
if (depth === 'summary') {
|
|
287
|
-
return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
|
|
104
|
+
return { kind, frontmatter, body: documentForFile(parsed).bodyBeforeSessionLog(body) };
|
|
288
105
|
}
|
|
289
106
|
return { kind, frontmatter, body };
|
|
290
107
|
}
|
|
@@ -313,301 +130,142 @@ export function createHafez(config) {
|
|
|
313
130
|
getIndex().upsertFromFile(u.slug, u.kind);
|
|
314
131
|
}
|
|
315
132
|
}
|
|
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;
|
|
133
|
+
async function runMutations(operations, single) {
|
|
134
|
+
if (readOnly)
|
|
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);
|
|
140
|
+
return withLock(async () => {
|
|
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);
|
|
344
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));
|
|
345
152
|
}
|
|
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
|
-
};
|
|
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
|
+
}
|
|
431
163
|
}
|
|
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;
|
|
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
|
+
}
|
|
485
189
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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;
|
|
491
201
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
+
}
|
|
501
224
|
};
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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();
|
|
518
245
|
}
|
|
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
|
-
};
|
|
246
|
+
return { applied, results };
|
|
247
|
+
});
|
|
551
248
|
}
|
|
552
249
|
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
|
-
});
|
|
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;
|
|
565
255
|
}
|
|
566
256
|
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
|
-
});
|
|
257
|
+
const { applied } = await runMutations([{ op: 'capture', name, notes }], { action: 'capture' });
|
|
258
|
+
return applied[0].result.slug;
|
|
577
259
|
}
|
|
578
260
|
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
|
-
});
|
|
261
|
+
const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
|
|
262
|
+
return { matched_action: applied[0].matchedAction };
|
|
589
263
|
}
|
|
590
264
|
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
|
-
});
|
|
265
|
+
await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
|
|
600
266
|
}
|
|
601
267
|
async function unlink(slug, target, relation) {
|
|
602
|
-
|
|
603
|
-
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
604
|
-
return withLock(async () => {
|
|
605
|
-
const a = applyOperation({ op: 'unlink', slug, target, relation });
|
|
606
|
-
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
607
|
-
updateIndexPostCommit([a]);
|
|
608
|
-
regenerateIndexSafe();
|
|
609
|
-
onWrite?.(slug, 'unlink');
|
|
610
|
-
});
|
|
268
|
+
await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
|
|
611
269
|
}
|
|
612
270
|
// "Recent" with day-granularity dates: primary sort is the frontmatter day,
|
|
613
271
|
// ties break on git commit time (the real recency signal), then name for
|
|
@@ -656,8 +314,18 @@ export function createHafez(config) {
|
|
|
656
314
|
if (!fp)
|
|
657
315
|
return null;
|
|
658
316
|
const kind = kindFromPath(fp);
|
|
659
|
-
|
|
660
|
-
|
|
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') {
|
|
320
|
+
try {
|
|
321
|
+
const pf = parseFilePath(fp);
|
|
322
|
+
const subtype = pf.kind === 'knowledge' ? pf.frontmatter.subtype : undefined;
|
|
323
|
+
return { kind, type: 'knowledge', subtype };
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return { kind, type: 'knowledge' };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
661
329
|
try {
|
|
662
330
|
const pf = parseFilePath(fp);
|
|
663
331
|
return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
|
|
@@ -676,118 +344,59 @@ export function createHafez(config) {
|
|
|
676
344
|
buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
|
|
677
345
|
};
|
|
678
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 = [];
|
|
679
352
|
for (const [index, op] of operations.entries()) {
|
|
680
353
|
// Shape and semantics in one pass: library callers get the same runtime
|
|
681
354
|
// guarantee the CLI's JSON path has always had.
|
|
682
355
|
const shape = BatchOperationSchema.safeParse(op);
|
|
683
356
|
if (!shape.success) {
|
|
357
|
+
parsedOps.push(op);
|
|
684
358
|
const raw = op;
|
|
685
359
|
const label = raw && typeof raw.slug === 'string' ? raw.slug
|
|
686
360
|
: raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
|
|
687
|
-
|
|
361
|
+
const entry = {
|
|
688
362
|
index,
|
|
689
363
|
op: typeof raw?.op === 'string' ? raw.op : '?',
|
|
690
364
|
slug: label,
|
|
691
365
|
errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
692
|
-
}
|
|
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';
|
|
375
|
+
}
|
|
376
|
+
report.push(entry);
|
|
693
377
|
continue;
|
|
694
378
|
}
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
const
|
|
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 };
|
|
698
384
|
if (res.created)
|
|
699
385
|
entry.created = true;
|
|
700
386
|
if (res.warning)
|
|
701
387
|
entry.warning = res.warning;
|
|
388
|
+
if (res.primaryCode)
|
|
389
|
+
entry.primaryCode = res.primaryCode;
|
|
702
390
|
report.push(entry);
|
|
703
391
|
}
|
|
704
|
-
return { valid: report.every(o => o.errors.length === 0), operations: report };
|
|
392
|
+
return { valid: report.every(o => o.errors.length === 0), operations: report, parsedOps };
|
|
705
393
|
}
|
|
706
394
|
function formatOpErrors(report) {
|
|
707
|
-
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)));
|
|
708
396
|
}
|
|
709
397
|
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
|
-
});
|
|
398
|
+
const { results } = await runMutations(operations, null);
|
|
399
|
+
return results;
|
|
791
400
|
}
|
|
792
401
|
async function validate() {
|
|
793
402
|
const report = {
|
|
@@ -878,117 +487,14 @@ export function createHafez(config) {
|
|
|
878
487
|
}
|
|
879
488
|
return report;
|
|
880
489
|
}
|
|
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 };
|
|
953
|
-
}
|
|
954
490
|
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
|
-
});
|
|
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;
|
|
992
498
|
}
|
|
993
499
|
const emptyStats = {
|
|
994
500
|
counts: { active: 0, paused: 0, done: 0 },
|
|
@@ -1000,59 +506,38 @@ export function createHafez(config) {
|
|
|
1000
506
|
read,
|
|
1001
507
|
update,
|
|
1002
508
|
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
|
-
},
|
|
509
|
+
query: async (opts) => withIndex(idx => idx.queryEntities(opts), { items: [], total: 0 }),
|
|
510
|
+
queryUnified: async (opts) => withIndex(idx => idx.queryUnified(opts), { items: [], total: 0 }),
|
|
1015
511
|
create,
|
|
1016
512
|
capture,
|
|
1017
513
|
link,
|
|
1018
514
|
unlink,
|
|
1019
515
|
batch,
|
|
1020
|
-
|
|
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
|
+
},
|
|
1021
523
|
sync: async () => {
|
|
1022
524
|
if (readOnly)
|
|
1023
525
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1024
526
|
return withLock(() => journal.sync());
|
|
1025
527
|
},
|
|
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
|
-
},
|
|
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 }),
|
|
1044
531
|
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
|
-
},
|
|
532
|
+
search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
|
|
1052
533
|
rebuildIndex: async () => {
|
|
1053
534
|
if (readOnly)
|
|
1054
535
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1055
|
-
|
|
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(); });
|
|
1056
541
|
},
|
|
1057
542
|
stats: async () => {
|
|
1058
543
|
const idx = getIndex();
|
|
@@ -1068,5 +553,5 @@ export function createHafez(config) {
|
|
|
1068
553
|
};
|
|
1069
554
|
}
|
|
1070
555
|
export { HafezError } from './types.js';
|
|
1071
|
-
export { getBrief, getNextActions
|
|
556
|
+
export { getBrief, getNextActions } from './document.js';
|
|
1072
557
|
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|