hafez 1.2.0 → 1.3.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/dist/batch-ops.d.ts +187 -0
- package/dist/batch-ops.js +322 -0
- package/dist/cli/args.d.ts +15 -0
- package/dist/cli/args.js +49 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +0 -108
- package/dist/cli/commands.js +154 -252
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +20 -5
- package/dist/cli/index.js +45 -65
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli.js +0 -0
- package/dist/contracts.d.ts +6 -0
- package/dist/contracts.js +8 -2
- package/dist/db.js +14 -28
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +108 -0
- package/dist/{sections.js → document.js} +173 -22
- package/dist/export-okf.js +10 -22
- package/dist/git.d.ts +7 -8
- package/dist/git.js +39 -21
- package/dist/index.d.ts +3 -4
- package/dist/index.js +419 -517
- package/dist/knowledge-index.js +9 -7
- package/dist/merge.js +13 -25
- package/dist/query.d.ts +1 -1
- package/dist/query.js +3 -1
- package/dist/types.d.ts +48 -1
- package/dist/vault.d.ts +33 -6
- package/dist/vault.js +65 -8
- package/package.json +2 -2
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/sections.d.ts +0 -54
package/dist/index.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath } from './vault.js';
|
|
3
|
-
import {
|
|
2
|
+
import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, scanVaultDir, archiveLogPath, isIndexedRelPath } from './vault.js';
|
|
3
|
+
import { createGitJournal } from './git.js';
|
|
4
4
|
import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
|
|
5
5
|
import { bodyTemplate, knowledgeBodyTemplate } from './templates.js';
|
|
6
|
-
import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry } from './
|
|
7
|
-
import { setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findNextStructuralHeading, findSection } from './sections.js';
|
|
6
|
+
import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry, setBrief, removeBrief, addNextAction, completeNextAction, removeNextAction, clearNextActions, syncRelatedSection, findSection, setSection, appendToSection, prependSessionLogEntry, bodyBeforeSessionLog } from './document.js';
|
|
8
7
|
import { queryEntities, queryChildren, queryRelatedTo, queryKnowledge, queryUnified } from './query.js';
|
|
9
|
-
import { getContract } from './contracts.js';
|
|
8
|
+
import { getContract, ENTITY_STATUSES, CONFIDENCE_LEVELS } from './contracts.js';
|
|
10
9
|
import { createIndex } from './db.js';
|
|
11
10
|
import { generateVaultIndex } from './knowledge-index.js';
|
|
12
11
|
import { HafezError } from './types.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { BatchOperationSchema, specFor } from './batch-ops.js';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
|
|
14
|
+
import { join, relative, basename, dirname } 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;
|
|
18
|
+
// The persistence seam (CONTEXT.md: Journal). Everything below talks to
|
|
19
|
+
// history through this port; git is just the default adapter.
|
|
20
|
+
const journal = config.persistence ?? createGitJournal(vaultPath, gitConfig);
|
|
18
21
|
// Two-level write lock: an in-process promise chain serializes calls within
|
|
19
22
|
// this instance, then a cross-process advisory lock (proper-lockfile, mkdir
|
|
20
23
|
// based — works on network/drvfs mounts) serializes against other hafez
|
|
@@ -164,50 +167,17 @@ export function createHafez(config) {
|
|
|
164
167
|
fm['session-date'] = kf['session-date'];
|
|
165
168
|
return fm;
|
|
166
169
|
}
|
|
167
|
-
function replaceSection(body, heading, content, insertBefore) {
|
|
168
|
-
const idx = body.indexOf(heading);
|
|
169
|
-
if (idx >= 0) {
|
|
170
|
-
const afterPos = idx + heading.length;
|
|
171
|
-
const nextH = findNextStructuralHeading(body, afterPos);
|
|
172
|
-
if (nextH !== -1) {
|
|
173
|
-
return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
|
|
174
|
-
}
|
|
175
|
-
return body.slice(0, afterPos) + '\n\n' + content + '\n';
|
|
176
|
-
}
|
|
177
|
-
if (insertBefore) {
|
|
178
|
-
const beforeIdx = body.indexOf(insertBefore);
|
|
179
|
-
if (beforeIdx >= 0) {
|
|
180
|
-
return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return body + '\n\n' + heading + '\n\n' + content + '\n';
|
|
184
|
-
}
|
|
185
|
-
function insertSessionLog(body, entry) {
|
|
186
|
-
const slHeader = '## Session Log';
|
|
187
|
-
const slIdx = body.indexOf(slHeader);
|
|
188
|
-
if (slIdx >= 0) {
|
|
189
|
-
const insertPos = slIdx + slHeader.length;
|
|
190
|
-
return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
|
|
191
|
-
}
|
|
192
|
-
return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
|
|
193
|
-
}
|
|
194
|
-
function appendToSection(body, heading, content) {
|
|
195
|
-
const idx = body.indexOf(heading);
|
|
196
|
-
if (idx >= 0) {
|
|
197
|
-
const afterPos = idx + heading.length;
|
|
198
|
-
const nextH = findNextStructuralHeading(body, afterPos);
|
|
199
|
-
if (nextH !== -1) {
|
|
200
|
-
return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
|
|
201
|
-
}
|
|
202
|
-
return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
|
|
203
|
-
}
|
|
204
|
-
return body + '\n\n' + heading + '\n\n' + content + '\n';
|
|
205
|
-
}
|
|
206
170
|
function applyUpdateFields(fm, body, fields) {
|
|
207
|
-
|
|
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
|
+
}
|
|
208
177
|
fm.status = fields.status;
|
|
178
|
+
}
|
|
209
179
|
if (fields.current_state !== undefined) {
|
|
210
|
-
body =
|
|
180
|
+
body = setSection(body, 'Current State', fields.current_state, 'Session Log');
|
|
211
181
|
}
|
|
212
182
|
if (fields.brief !== undefined) {
|
|
213
183
|
body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
|
|
@@ -236,7 +206,7 @@ export function createHafez(config) {
|
|
|
236
206
|
const logErrors = validateSessionLogEntry(fields.session_log);
|
|
237
207
|
if (logErrors.length > 0)
|
|
238
208
|
throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
|
|
239
|
-
body =
|
|
209
|
+
body = prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
|
|
240
210
|
}
|
|
241
211
|
// Domain (shared) + knowledge-only metadata
|
|
242
212
|
if (fields.domain !== undefined) {
|
|
@@ -257,8 +227,12 @@ export function createHafez(config) {
|
|
|
257
227
|
else
|
|
258
228
|
fm.resource = fields.resource;
|
|
259
229
|
}
|
|
260
|
-
if (fields.confidence !== undefined)
|
|
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
|
+
}
|
|
261
234
|
fm.confidence = fields.confidence;
|
|
235
|
+
}
|
|
262
236
|
if (fields.tags !== undefined)
|
|
263
237
|
fm.tags = fields.tags;
|
|
264
238
|
if (fields.related !== undefined) {
|
|
@@ -269,18 +243,18 @@ export function createHafez(config) {
|
|
|
269
243
|
fm.related = fields.related;
|
|
270
244
|
}
|
|
271
245
|
if (fields.synthesis !== undefined) {
|
|
272
|
-
body =
|
|
246
|
+
body = setSection(body, 'Synthesis', fields.synthesis);
|
|
273
247
|
}
|
|
274
248
|
if (fields.add_evidence) {
|
|
275
249
|
const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
|
|
276
|
-
body = appendToSection(body, '
|
|
250
|
+
body = appendToSection(body, 'Evidence', sanitized);
|
|
277
251
|
fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
|
|
278
252
|
fm['last-reinforced'] = today();
|
|
279
253
|
// NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
|
|
280
254
|
// removed. Confidence changes are now a deliberate decision via update --confidence.
|
|
281
255
|
}
|
|
282
256
|
if (fields.add_source) {
|
|
283
|
-
body = appendToSection(body, '
|
|
257
|
+
body = appendToSection(body, 'Sources', fields.add_source);
|
|
284
258
|
}
|
|
285
259
|
if (fm['last-touched'] !== today())
|
|
286
260
|
fm['last-touched'] = today();
|
|
@@ -303,137 +277,323 @@ export function createHafez(config) {
|
|
|
303
277
|
if (!filePath)
|
|
304
278
|
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
|
|
305
279
|
const parsed = parseFilePath(filePath);
|
|
280
|
+
// findFile only resolves the indexed kinds, so the session arm is unreachable
|
|
281
|
+
const kind = parsed.kind === 'session' ? 'knowledge' : parsed.kind;
|
|
306
282
|
const frontmatter = parsed.frontmatter;
|
|
307
283
|
const body = parsed.body;
|
|
308
284
|
if (depth === 'frontmatter')
|
|
309
|
-
return { frontmatter, body: '' };
|
|
285
|
+
return { kind, frontmatter, body: '' };
|
|
310
286
|
if (depth === 'summary') {
|
|
311
|
-
|
|
312
|
-
return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
|
|
287
|
+
return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
|
|
313
288
|
}
|
|
314
|
-
return { frontmatter, body };
|
|
289
|
+
return { kind, frontmatter, body };
|
|
315
290
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
291
|
+
function rollbackFiles(originals) {
|
|
292
|
+
for (const [path, content] of originals) {
|
|
293
|
+
if (content === null) {
|
|
294
|
+
try {
|
|
295
|
+
unlinkSync(path);
|
|
296
|
+
}
|
|
297
|
+
catch { }
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
writeFileSync(path, content);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// All SQLite writes happen here, strictly after the git commit succeeded.
|
|
305
|
+
// A failed commit rolls back files only — the index was never touched, so
|
|
306
|
+
// files, git, and index stay consistent (pre-collapse, promote removed its
|
|
307
|
+
// index row mid-apply and a rollback lost it).
|
|
308
|
+
function updateIndexPostCommit(applied) {
|
|
309
|
+
for (const a of applied) {
|
|
310
|
+
for (const slug of a.indexRemovals)
|
|
311
|
+
getIndex().removeItem(slug);
|
|
312
|
+
for (const u of a.indexUpserts)
|
|
313
|
+
getIndex().upsertFromFile(u.slug, u.kind);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// The single apply path for every mutation. Writes vault files but never
|
|
317
|
+
// touches git or SQLite: callers commit [...filesWritten, ...filesDeleted]
|
|
318
|
+
// under commitMessage (single ops) or one composed batch message, then run
|
|
319
|
+
// updateIndexPostCommit. captureOriginal lets batch() snapshot files for
|
|
320
|
+
// rollback before they change.
|
|
321
|
+
function applyOperation(op, captureOriginal = () => { }) {
|
|
322
|
+
if (op.op === 'update') {
|
|
323
|
+
const filePath = findFile(op.slug);
|
|
324
|
+
if (!filePath)
|
|
325
|
+
throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
|
|
326
|
+
captureOriginal(filePath);
|
|
327
|
+
const kind = kindFromPath(filePath);
|
|
328
|
+
validateKindFields(kind, op.fields);
|
|
329
|
+
const parsedFile = parseFilePath(filePath);
|
|
330
|
+
let fm = parsedFile.frontmatter;
|
|
331
|
+
let body = parsedFile.body;
|
|
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;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
body = syncRelatedSection(body, fm);
|
|
347
|
+
writeFileSync(filePath, serializeFile(fm, body));
|
|
348
|
+
const files = [relative(vaultPath, filePath)];
|
|
349
|
+
if (existsSync(archivePath))
|
|
350
|
+
files.push(relative(vaultPath, archivePath));
|
|
351
|
+
return {
|
|
352
|
+
filesWritten: files,
|
|
353
|
+
filesDeleted: [],
|
|
354
|
+
result: { op: 'update', slug: op.slug, status: 'ok' },
|
|
355
|
+
commitMessage: `update: ${op.slug}`,
|
|
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);
|
|
321
412
|
if (exists(slug))
|
|
322
413
|
throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
|
|
323
|
-
if (kind === 'entity') {
|
|
324
|
-
const ef = fields;
|
|
325
|
-
const fm = buildEntityFrontmatter(name, ef);
|
|
414
|
+
if (op.kind === 'entity') {
|
|
415
|
+
const ef = op.fields;
|
|
416
|
+
const fm = buildEntityFrontmatter(op.name, ef);
|
|
326
417
|
let body = buildEntityBody(ef);
|
|
327
418
|
const filePath = entityPath(slug);
|
|
328
|
-
|
|
419
|
+
captureOriginal(filePath);
|
|
420
|
+
mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
|
|
329
421
|
body = syncRelatedSection(body, fm);
|
|
330
422
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
};
|
|
334
431
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
return withLock(async () => {
|
|
355
|
-
const slug = slugify(name);
|
|
432
|
+
const kf = (op.fields || {});
|
|
433
|
+
const fm = buildKnowledgeFrontmatter(op.name, kf);
|
|
434
|
+
let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
|
|
435
|
+
const filePath = knowledgePath(slug);
|
|
436
|
+
captureOriginal(filePath);
|
|
437
|
+
mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
|
|
438
|
+
body = syncRelatedSection(body, fm);
|
|
439
|
+
writeFileSync(filePath, serializeFile(fm, body));
|
|
440
|
+
return {
|
|
441
|
+
filesWritten: [relative(vaultPath, filePath)],
|
|
442
|
+
filesDeleted: [],
|
|
443
|
+
result: { op: 'create', slug, status: 'ok', created: true },
|
|
444
|
+
commitMessage: `create: ${op.name}`,
|
|
445
|
+
indexRemovals: [],
|
|
446
|
+
indexUpserts: [{ slug, kind: 'knowledge' }],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (op.op === 'capture') {
|
|
450
|
+
const slug = slugify(op.name);
|
|
356
451
|
if (exists(slug))
|
|
357
452
|
throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
|
|
358
453
|
const fm = {
|
|
359
|
-
name,
|
|
454
|
+
name: op.name,
|
|
360
455
|
type: 'capture',
|
|
361
456
|
status: 'active',
|
|
362
457
|
created: today(),
|
|
363
458
|
'last-touched': today(),
|
|
364
459
|
};
|
|
365
|
-
const body = bodyTemplate('capture', { notes });
|
|
366
460
|
const filePath = entityPath(slug);
|
|
367
|
-
|
|
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;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
const related = fm.related || [];
|
|
488
|
+
if (!related.includes(op.target))
|
|
489
|
+
related.push(op.target);
|
|
490
|
+
fm.related = related;
|
|
491
|
+
}
|
|
492
|
+
body = syncRelatedSection(body, fm);
|
|
368
493
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
369
|
-
|
|
370
|
-
|
|
494
|
+
return {
|
|
495
|
+
filesWritten: [relative(vaultPath, filePath)],
|
|
496
|
+
filesDeleted: [],
|
|
497
|
+
result: { op: 'link', slug: op.slug, status: 'ok' },
|
|
498
|
+
commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
|
|
499
|
+
indexRemovals: [],
|
|
500
|
+
indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (op.op === 'unlink') {
|
|
504
|
+
const filePath = findFile(op.slug);
|
|
505
|
+
if (!filePath)
|
|
506
|
+
throw new HafezError('NOT_FOUND', `'${op.slug}' not found`);
|
|
507
|
+
captureOriginal(filePath);
|
|
508
|
+
const parsedFile = parseFilePath(filePath);
|
|
509
|
+
const fm = parsedFile.frontmatter;
|
|
510
|
+
let body = parsedFile.body;
|
|
511
|
+
if (op.relation === 'parent' && fm.parent === op.target) {
|
|
512
|
+
delete fm.parent;
|
|
513
|
+
}
|
|
514
|
+
else if (op.relation === 'related' && fm.related) {
|
|
515
|
+
fm.related = fm.related.filter((r) => r !== op.target);
|
|
516
|
+
if (fm.related.length === 0)
|
|
517
|
+
delete fm.related;
|
|
518
|
+
}
|
|
519
|
+
body = syncRelatedSection(body, fm);
|
|
520
|
+
writeFileSync(filePath, serializeFile(fm, body));
|
|
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
|
+
};
|
|
551
|
+
}
|
|
552
|
+
async function create(kind, name, fields) {
|
|
553
|
+
if (readOnly)
|
|
554
|
+
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
555
|
+
return withLock(async () => {
|
|
556
|
+
const a = kind === 'entity'
|
|
557
|
+
? applyOperation({ op: 'create', kind, name, fields: fields })
|
|
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');
|
|
371
562
|
regenerateIndexSafe();
|
|
372
|
-
|
|
373
|
-
|
|
563
|
+
return a.result.slug;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
async function capture(name, notes) {
|
|
567
|
+
if (readOnly)
|
|
568
|
+
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
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;
|
|
374
576
|
});
|
|
375
577
|
}
|
|
376
578
|
async function update(slug, fields) {
|
|
377
579
|
if (readOnly)
|
|
378
580
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
379
581
|
return withLock(async () => {
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const kind = kindFromPath(filePath);
|
|
384
|
-
validateKindFields(kind, fields);
|
|
385
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
386
|
-
const applied = applyUpdateFields(fm, body, fields);
|
|
387
|
-
fm = applied.fm;
|
|
388
|
-
body = applied.body;
|
|
389
|
-
// Session log archival
|
|
390
|
-
if (countSessionLogEntries(body) >= 10) {
|
|
391
|
-
const extracted = extractOldestSessionLogEntry(body);
|
|
392
|
-
if (extracted) {
|
|
393
|
-
const archiveDir = join(vaultPath, 'entities', 'archive');
|
|
394
|
-
mkdirSync(archiveDir, { recursive: true });
|
|
395
|
-
const archivePath = join(archiveDir, `${slug}-log.md`);
|
|
396
|
-
appendFileSync(archivePath, extracted.entry + '\n\n');
|
|
397
|
-
body = extracted.remaining;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
body = syncRelatedSection(body, fm);
|
|
401
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
402
|
-
const files = [relative(vaultPath, filePath)];
|
|
403
|
-
const archivePath = join(vaultPath, 'entities', 'archive', `${slug}-log.md`);
|
|
404
|
-
if (existsSync(archivePath))
|
|
405
|
-
files.push(relative(vaultPath, archivePath));
|
|
406
|
-
await gitCommitAndPush(vaultPath, files, `update: ${slug}`, gitConfig);
|
|
407
|
-
getIndex().upsertFromFile(slug, kind);
|
|
582
|
+
const a = applyOperation({ op: 'update', slug, fields });
|
|
583
|
+
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
584
|
+
updateIndexPostCommit([a]);
|
|
408
585
|
regenerateIndexSafe();
|
|
409
586
|
onWrite?.(slug, 'update');
|
|
410
|
-
return { matched_action:
|
|
587
|
+
return { matched_action: a.matchedAction };
|
|
411
588
|
});
|
|
412
589
|
}
|
|
413
590
|
async function link(slug, target, relation) {
|
|
414
591
|
if (readOnly)
|
|
415
592
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
416
593
|
return withLock(async () => {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (!exists(target))
|
|
421
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
|
|
422
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
423
|
-
if (relation === 'parent') {
|
|
424
|
-
fm.parent = target;
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
const related = fm.related || [];
|
|
428
|
-
if (!related.includes(target))
|
|
429
|
-
related.push(target);
|
|
430
|
-
fm.related = related;
|
|
431
|
-
}
|
|
432
|
-
body = syncRelatedSection(body, fm);
|
|
433
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
434
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `link: ${slug} → ${target} (${relation})`, gitConfig);
|
|
435
|
-
const kind = kindFromPath(filePath);
|
|
436
|
-
getIndex().upsertFromFile(slug, kind);
|
|
594
|
+
const a = applyOperation({ op: 'link', slug, target, relation });
|
|
595
|
+
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
596
|
+
updateIndexPostCommit([a]);
|
|
437
597
|
regenerateIndexSafe();
|
|
438
598
|
onWrite?.(slug, 'link');
|
|
439
599
|
});
|
|
@@ -442,23 +602,9 @@ export function createHafez(config) {
|
|
|
442
602
|
if (readOnly)
|
|
443
603
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
444
604
|
return withLock(async () => {
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
449
|
-
if (relation === 'parent' && fm.parent === target) {
|
|
450
|
-
delete fm.parent;
|
|
451
|
-
}
|
|
452
|
-
else if (relation === 'related' && fm.related) {
|
|
453
|
-
fm.related = fm.related.filter((r) => r !== target);
|
|
454
|
-
if (fm.related.length === 0)
|
|
455
|
-
delete fm.related;
|
|
456
|
-
}
|
|
457
|
-
body = syncRelatedSection(body, fm);
|
|
458
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
459
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
|
|
460
|
-
const kind = kindFromPath(filePath);
|
|
461
|
-
getIndex().upsertFromFile(slug, kind);
|
|
605
|
+
const a = applyOperation({ op: 'unlink', slug, target, relation });
|
|
606
|
+
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
607
|
+
updateIndexPostCommit([a]);
|
|
462
608
|
regenerateIndexSafe();
|
|
463
609
|
onWrite?.(slug, 'unlink');
|
|
464
610
|
});
|
|
@@ -478,8 +624,8 @@ export function createHafez(config) {
|
|
|
478
624
|
const hasTies = new Set(candidates.map(day)).size < candidates.length;
|
|
479
625
|
if (!hasTies)
|
|
480
626
|
return rows.slice(0, 5);
|
|
481
|
-
const times = await
|
|
482
|
-
const gitTime = (r) => times.get(
|
|
627
|
+
const times = await journal.fileTimes(mode);
|
|
628
|
+
const gitTime = (r) => times.get(`${kindDir('entity')}/${r.slug}.md`) ?? '';
|
|
483
629
|
candidates.sort((a, b) => {
|
|
484
630
|
const dayCmp = day(b).localeCompare(day(a));
|
|
485
631
|
if (dayCmp !== 0)
|
|
@@ -499,139 +645,60 @@ export function createHafez(config) {
|
|
|
499
645
|
// slug → what it would be after earlier ops in this batch
|
|
500
646
|
const simulated = new Map();
|
|
501
647
|
const simulatedSessions = new Set();
|
|
502
|
-
const resolve = (slug) => {
|
|
503
|
-
const sim = simulated.get(slug);
|
|
504
|
-
if (sim)
|
|
505
|
-
return sim;
|
|
506
|
-
const fp = findFile(slug);
|
|
507
|
-
if (!fp)
|
|
508
|
-
return null;
|
|
509
|
-
const kind = kindFromPath(fp);
|
|
510
|
-
if (kind === 'knowledge')
|
|
511
|
-
return { kind, type: 'knowledge' };
|
|
512
|
-
try {
|
|
513
|
-
return { kind, type: parseFilePath(fp).frontmatter.type };
|
|
514
|
-
}
|
|
515
|
-
catch {
|
|
516
|
-
return { kind, type: 'entity' };
|
|
517
|
-
}
|
|
518
|
-
};
|
|
519
648
|
const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
else {
|
|
536
|
-
try {
|
|
537
|
-
validateKindFields(target.kind, op.fields);
|
|
538
|
-
}
|
|
539
|
-
catch (err) {
|
|
540
|
-
pushErr(err);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
if (op.fields.session_log) {
|
|
544
|
-
const logErrors = validateSessionLogEntry(op.fields.session_log);
|
|
545
|
-
if (logErrors.length > 0)
|
|
546
|
-
errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
|
|
547
|
-
}
|
|
548
|
-
if (op.fields.related) {
|
|
549
|
-
for (const r of op.fields.related) {
|
|
550
|
-
if (!existsAnywhere(r))
|
|
551
|
-
errors.push(`Related slug '${r}' does not exist`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
else if (op.op === 'create' && op.kind === 'session') {
|
|
556
|
-
slug = slugify(op.name);
|
|
557
|
-
if (exists(slug) || existsSync(join(vaultPath, 'sessions', `${slug}.md`)) || simulatedSessions.has(slug)) {
|
|
558
|
-
warning = `session '${slug}' already exists — op will be skipped (batch continues)`;
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
simulatedSessions.add(slug);
|
|
562
|
-
created = true;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
else if (op.op === 'create') {
|
|
566
|
-
slug = slugify(op.name);
|
|
567
|
-
if (existsAnywhere(slug))
|
|
568
|
-
errors.push(`Slug '${slug}' already exists`);
|
|
569
|
-
if (op.kind === 'entity') {
|
|
570
|
-
try {
|
|
571
|
-
buildEntityFrontmatter(op.name, op.fields, existsAnywhere);
|
|
572
|
-
}
|
|
573
|
-
catch (err) {
|
|
574
|
-
pushErr(err);
|
|
575
|
-
}
|
|
576
|
-
simulated.set(slug, { kind: 'entity', type: op.fields.type });
|
|
577
|
-
}
|
|
578
|
-
else {
|
|
579
|
-
try {
|
|
580
|
-
buildKnowledgeFrontmatter(op.name, (op.fields || {}), existsAnywhere);
|
|
581
|
-
}
|
|
582
|
-
catch (err) {
|
|
583
|
-
pushErr(err);
|
|
584
|
-
}
|
|
585
|
-
simulated.set(slug, { kind: 'knowledge', type: 'knowledge' });
|
|
649
|
+
// Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
|
|
650
|
+
const ctx = {
|
|
651
|
+
resolve: (slug) => {
|
|
652
|
+
const sim = simulated.get(slug);
|
|
653
|
+
if (sim)
|
|
654
|
+
return sim;
|
|
655
|
+
const fp = findFile(slug);
|
|
656
|
+
if (!fp)
|
|
657
|
+
return null;
|
|
658
|
+
const kind = kindFromPath(fp);
|
|
659
|
+
if (kind === 'knowledge')
|
|
660
|
+
return { kind, type: 'knowledge' };
|
|
661
|
+
try {
|
|
662
|
+
const pf = parseFilePath(fp);
|
|
663
|
+
return { kind, type: pf.kind === 'entity' ? pf.frontmatter.type : 'entity' };
|
|
586
664
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
else if (op.op === 'capture') {
|
|
590
|
-
slug = slugify(op.name);
|
|
591
|
-
if (existsAnywhere(slug))
|
|
592
|
-
errors.push(`Slug '${slug}' already exists`);
|
|
593
|
-
simulated.set(slug, { kind: 'entity', type: 'capture' });
|
|
594
|
-
created = errors.length === 0;
|
|
595
|
-
}
|
|
596
|
-
else if (op.op === 'link') {
|
|
597
|
-
slug = op.slug;
|
|
598
|
-
if (!resolve(op.slug))
|
|
599
|
-
errors.push(`'${op.slug}' not found`);
|
|
600
|
-
if (!existsAnywhere(op.target))
|
|
601
|
-
errors.push(`Target '${op.target}' does not exist`);
|
|
602
|
-
}
|
|
603
|
-
else if (op.op === 'unlink') {
|
|
604
|
-
slug = op.slug;
|
|
605
|
-
if (!resolve(op.slug))
|
|
606
|
-
errors.push(`'${op.slug}' not found`);
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
609
|
-
// promote
|
|
610
|
-
slug = op.slug;
|
|
611
|
-
const cur = resolve(op.slug);
|
|
612
|
-
if (!cur)
|
|
613
|
-
errors.push(`'${op.slug}' not found`);
|
|
614
|
-
else {
|
|
615
|
-
const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
|
|
616
|
-
try {
|
|
617
|
-
const contract = getContract(currentType);
|
|
618
|
-
if (!contract.canPromoteTo.includes(op.target)) {
|
|
619
|
-
errors.push(`Cannot promote ${currentType} to ${op.target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
|
|
620
|
-
}
|
|
621
|
-
else {
|
|
622
|
-
simulated.set(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
catch (err) {
|
|
626
|
-
pushErr(err);
|
|
627
|
-
}
|
|
665
|
+
catch {
|
|
666
|
+
return { kind, type: 'entity' };
|
|
628
667
|
}
|
|
668
|
+
},
|
|
669
|
+
existsAnywhere,
|
|
670
|
+
sessionExists: (slug) => existsSync(join(vaultPath, kindDir('session'), `${slug}.md`)) || simulatedSessions.has(slug),
|
|
671
|
+
simulate: (slug, info) => { simulated.set(slug, info); },
|
|
672
|
+
simulateSession: (slug) => { simulatedSessions.add(slug); },
|
|
673
|
+
validateKindFields,
|
|
674
|
+
validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
|
|
675
|
+
buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
|
|
676
|
+
buildKnowledgeFrontmatter: (name, fields) => { buildKnowledgeFrontmatter(name, fields, existsAnywhere); },
|
|
677
|
+
};
|
|
678
|
+
const report = [];
|
|
679
|
+
for (const [index, op] of operations.entries()) {
|
|
680
|
+
// Shape and semantics in one pass: library callers get the same runtime
|
|
681
|
+
// guarantee the CLI's JSON path has always had.
|
|
682
|
+
const shape = BatchOperationSchema.safeParse(op);
|
|
683
|
+
if (!shape.success) {
|
|
684
|
+
const raw = op;
|
|
685
|
+
const label = raw && typeof raw.slug === 'string' ? raw.slug
|
|
686
|
+
: raw && typeof raw.name === 'string' ? slugify(raw.name) : '';
|
|
687
|
+
report.push({
|
|
688
|
+
index,
|
|
689
|
+
op: typeof raw?.op === 'string' ? raw.op : '?',
|
|
690
|
+
slug: label,
|
|
691
|
+
errors: shape.error.issues.map(i => `invalid shape at ${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
692
|
+
});
|
|
693
|
+
continue;
|
|
629
694
|
}
|
|
630
|
-
const
|
|
631
|
-
|
|
695
|
+
const spec = specFor(op);
|
|
696
|
+
const res = spec.check(op, ctx);
|
|
697
|
+
const entry = { index, op: op.op, slug: res.slug, errors: res.errors };
|
|
698
|
+
if (res.created)
|
|
632
699
|
entry.created = true;
|
|
633
|
-
if (warning)
|
|
634
|
-
entry.warning = warning;
|
|
700
|
+
if (res.warning)
|
|
701
|
+
entry.warning = res.warning;
|
|
635
702
|
report.push(entry);
|
|
636
703
|
}
|
|
637
704
|
return { valid: report.every(o => o.errors.length === 0), operations: report };
|
|
@@ -657,180 +724,20 @@ export function createHafez(config) {
|
|
|
657
724
|
originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
|
|
658
725
|
}
|
|
659
726
|
}
|
|
660
|
-
const
|
|
661
|
-
const affectedSlugs = [];
|
|
727
|
+
const appliedOps = [];
|
|
662
728
|
const results = [];
|
|
663
729
|
let currentOp = -1;
|
|
664
730
|
try {
|
|
665
731
|
for (const op of operations) {
|
|
666
732
|
currentOp++;
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
captureOriginal(filePath);
|
|
672
|
-
validateKindFields(kindFromPath(filePath), op.fields);
|
|
673
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
674
|
-
const applied = applyUpdateFields(fm, body, op.fields);
|
|
675
|
-
applied.body = syncRelatedSection(applied.body, applied.fm);
|
|
676
|
-
writeFileSync(filePath, serializeFile(applied.fm, applied.body));
|
|
677
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
678
|
-
affectedSlugs.push(op.slug);
|
|
679
|
-
results.push({ op: 'update', slug: op.slug, status: 'ok' });
|
|
680
|
-
}
|
|
681
|
-
else if (op.op === 'create' && op.kind === 'session') {
|
|
682
|
-
const slug = slugify(op.name);
|
|
683
|
-
if (exists(slug)) {
|
|
684
|
-
results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
|
|
685
|
-
continue;
|
|
686
|
-
}
|
|
687
|
-
const sessionDir = join(vaultPath, 'sessions');
|
|
688
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
689
|
-
const filePath = join(sessionDir, `${slug}.md`);
|
|
690
|
-
if (existsSync(filePath)) {
|
|
691
|
-
results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
|
|
692
|
-
continue;
|
|
693
|
-
}
|
|
694
|
-
captureOriginal(filePath);
|
|
695
|
-
const fm = { name: op.name, created: today() };
|
|
696
|
-
const kf = (op.fields ?? {});
|
|
697
|
-
if (kf['session-date'])
|
|
698
|
-
fm['session-date'] = kf['session-date'];
|
|
699
|
-
if (kf.related?.length)
|
|
700
|
-
fm.related = kf.related;
|
|
701
|
-
// Session files use sections: Summary, Entities Touched, Decisions
|
|
702
|
-
const sections = ['Summary', 'Entities Touched', 'Decisions'];
|
|
703
|
-
const lines = [];
|
|
704
|
-
for (const section of sections) {
|
|
705
|
-
lines.push(`## ${section}`);
|
|
706
|
-
if (section === 'Summary' && kf.synthesis)
|
|
707
|
-
lines.push('', kf.synthesis);
|
|
708
|
-
lines.push('');
|
|
709
|
-
}
|
|
710
|
-
let sessionBody = lines.join('\n');
|
|
711
|
-
sessionBody = syncRelatedSection(sessionBody, fm);
|
|
712
|
-
writeFileSync(filePath, serializeFile(fm, sessionBody));
|
|
713
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
714
|
-
// Sessions are not indexed (db.ts only scans entities/ and knowledge/).
|
|
715
|
-
// They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
|
|
716
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
717
|
-
}
|
|
718
|
-
else if (op.op === 'create') {
|
|
719
|
-
const slug = slugify(op.name);
|
|
720
|
-
if (exists(slug))
|
|
721
|
-
throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
|
|
722
|
-
if (op.kind === 'entity') {
|
|
723
|
-
const ef = op.fields;
|
|
724
|
-
const fm = buildEntityFrontmatter(op.name, ef);
|
|
725
|
-
let entBody = buildEntityBody(ef);
|
|
726
|
-
const filePath = entityPath(slug);
|
|
727
|
-
captureOriginal(filePath);
|
|
728
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
729
|
-
entBody = syncRelatedSection(entBody, fm);
|
|
730
|
-
writeFileSync(filePath, serializeFile(fm, entBody));
|
|
731
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
732
|
-
affectedSlugs.push(slug);
|
|
733
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
const kf = (op.fields || {});
|
|
737
|
-
const fm = buildKnowledgeFrontmatter(op.name, kf);
|
|
738
|
-
let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
|
|
739
|
-
const filePath = knowledgePath(slug);
|
|
740
|
-
captureOriginal(filePath);
|
|
741
|
-
mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
|
|
742
|
-
body = syncRelatedSection(body, fm);
|
|
743
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
744
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
745
|
-
affectedSlugs.push(slug);
|
|
746
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
else if (op.op === 'capture') {
|
|
750
|
-
const slug = slugify(op.name);
|
|
751
|
-
if (exists(slug))
|
|
752
|
-
throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
|
|
753
|
-
const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
|
|
754
|
-
const filePath = entityPath(slug);
|
|
755
|
-
captureOriginal(filePath);
|
|
756
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
757
|
-
writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
|
|
758
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
759
|
-
affectedSlugs.push(slug);
|
|
760
|
-
results.push({ op: 'capture', slug, status: 'ok', created: true });
|
|
761
|
-
}
|
|
762
|
-
else if (op.op === 'link') {
|
|
763
|
-
const filePath = findFile(op.slug);
|
|
764
|
-
if (!filePath)
|
|
765
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
766
|
-
if (!exists(op.target))
|
|
767
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
|
|
768
|
-
captureOriginal(filePath);
|
|
769
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
770
|
-
if (op.relation === 'parent') {
|
|
771
|
-
fm.parent = op.target;
|
|
772
|
-
}
|
|
773
|
-
else {
|
|
774
|
-
const related = fm.related || [];
|
|
775
|
-
if (!related.includes(op.target))
|
|
776
|
-
related.push(op.target);
|
|
777
|
-
fm.related = related;
|
|
778
|
-
}
|
|
779
|
-
body = syncRelatedSection(body, fm);
|
|
780
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
781
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
782
|
-
affectedSlugs.push(op.slug);
|
|
783
|
-
results.push({ op: 'link', slug: op.slug, status: 'ok' });
|
|
784
|
-
}
|
|
785
|
-
else if (op.op === 'unlink') {
|
|
786
|
-
const filePath = findFile(op.slug);
|
|
787
|
-
if (!filePath)
|
|
788
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
789
|
-
captureOriginal(filePath);
|
|
790
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
791
|
-
if (op.relation === 'parent' && fm.parent === op.target)
|
|
792
|
-
delete fm.parent;
|
|
793
|
-
else if (op.relation === 'related' && fm.related) {
|
|
794
|
-
fm.related = fm.related.filter((r) => r !== op.target);
|
|
795
|
-
if (fm.related.length === 0)
|
|
796
|
-
delete fm.related;
|
|
797
|
-
}
|
|
798
|
-
body = syncRelatedSection(body, fm);
|
|
799
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
800
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
801
|
-
affectedSlugs.push(op.slug);
|
|
802
|
-
results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
|
|
803
|
-
}
|
|
804
|
-
else if (op.op === 'promote') {
|
|
805
|
-
const filePath = findFile(op.slug);
|
|
806
|
-
if (!filePath)
|
|
807
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
808
|
-
captureOriginal(filePath);
|
|
809
|
-
if (op.target === 'knowledge') {
|
|
810
|
-
captureOriginal(knowledgePath(op.slug));
|
|
811
|
-
}
|
|
812
|
-
const kind = kindFromPath(filePath);
|
|
813
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
814
|
-
const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
|
|
815
|
-
modifiedFiles.push(...promoteFiles);
|
|
816
|
-
affectedSlugs.push(op.slug);
|
|
817
|
-
results.push({ op: 'promote', slug: op.slug, status: 'ok' });
|
|
818
|
-
}
|
|
733
|
+
const a = applyOperation(op, captureOriginal);
|
|
734
|
+
results.push(a.result);
|
|
735
|
+
if (a.result.status === 'ok')
|
|
736
|
+
appliedOps.push(a);
|
|
819
737
|
}
|
|
820
738
|
}
|
|
821
739
|
catch (err) {
|
|
822
|
-
|
|
823
|
-
for (const [path, content] of originals) {
|
|
824
|
-
if (content === null) {
|
|
825
|
-
try {
|
|
826
|
-
unlinkSync(path);
|
|
827
|
-
}
|
|
828
|
-
catch { }
|
|
829
|
-
}
|
|
830
|
-
else {
|
|
831
|
-
writeFileSync(path, content);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
740
|
+
rollbackFiles(originals);
|
|
834
741
|
// Localise: which op failed, by index and slug/name. The validate
|
|
835
742
|
// phase above catches semantic errors pre-execution; this covers
|
|
836
743
|
// residual execution-time failures (races, disk errors).
|
|
@@ -841,46 +748,35 @@ export function createHafez(config) {
|
|
|
841
748
|
}
|
|
842
749
|
throw err;
|
|
843
750
|
}
|
|
844
|
-
// Single
|
|
845
|
-
const
|
|
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))];
|
|
846
754
|
const indexAfterBatch = () => {
|
|
847
|
-
|
|
848
|
-
const fp = findFile(slug);
|
|
849
|
-
if (fp) {
|
|
850
|
-
getIndex().upsertFromFile(slug, kindFromPath(fp));
|
|
851
|
-
}
|
|
852
|
-
}
|
|
755
|
+
updateIndexPostCommit(appliedOps);
|
|
853
756
|
// Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
|
|
854
|
-
const anyIndexedTouched =
|
|
757
|
+
const anyIndexedTouched = [...writtenAll, ...deletedAll].some(isIndexedRelPath);
|
|
855
758
|
if (anyIndexedTouched) {
|
|
856
759
|
regenerateIndexSafe();
|
|
857
760
|
}
|
|
858
|
-
|
|
859
|
-
|
|
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
|
+
}
|
|
860
767
|
};
|
|
861
|
-
if (
|
|
768
|
+
if (writtenAll.length + deletedAll.length > 0) {
|
|
862
769
|
try {
|
|
863
|
-
await
|
|
770
|
+
await journal.commit(writtenAll, deletedAll, `batch: ${operations.length} operations`);
|
|
864
771
|
}
|
|
865
772
|
catch (err) {
|
|
866
773
|
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
867
774
|
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
868
|
-
// committed — restore the captured originals so files,
|
|
869
|
-
// index stay consistent instead of leaving the batch
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
unlinkSync(path);
|
|
874
|
-
}
|
|
875
|
-
catch { }
|
|
876
|
-
}
|
|
877
|
-
else {
|
|
878
|
-
writeFileSync(path, content);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
// Also clear anything the failed commit left staged, or the next
|
|
882
|
-
// commit would publish the rolled-back content.
|
|
883
|
-
await gitUnstage(vaultPath, uniqueFiles);
|
|
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);
|
|
884
780
|
throw err;
|
|
885
781
|
}
|
|
886
782
|
// Push-stage failure: the batch commit exists locally ("changes
|
|
@@ -906,16 +802,16 @@ export function createHafez(config) {
|
|
|
906
802
|
const knowledgeSlugs = new Set();
|
|
907
803
|
const referencedKnowledge = new Set();
|
|
908
804
|
// Scan entities
|
|
909
|
-
const entDir = join(vaultPath, '
|
|
910
|
-
|
|
911
|
-
const files =
|
|
805
|
+
const entDir = join(vaultPath, kindDir('entity'));
|
|
806
|
+
{
|
|
807
|
+
const files = scanVaultDir(entDir);
|
|
912
808
|
report.total_entities = files.length;
|
|
913
|
-
for (const
|
|
914
|
-
const slug = basename(
|
|
809
|
+
for (const filePath of files) {
|
|
810
|
+
const slug = basename(filePath, '.md');
|
|
915
811
|
allSlugs.add(slug);
|
|
916
812
|
let fm;
|
|
917
813
|
try {
|
|
918
|
-
fm = parseFilePath(
|
|
814
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
919
815
|
}
|
|
920
816
|
catch {
|
|
921
817
|
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
@@ -939,17 +835,17 @@ export function createHafez(config) {
|
|
|
939
835
|
}
|
|
940
836
|
}
|
|
941
837
|
// Scan knowledge
|
|
942
|
-
const knDir = join(vaultPath, 'knowledge');
|
|
943
|
-
|
|
944
|
-
const files =
|
|
838
|
+
const knDir = join(vaultPath, kindDir('knowledge'));
|
|
839
|
+
{
|
|
840
|
+
const files = scanVaultDir(knDir);
|
|
945
841
|
report.total_knowledge = files.length;
|
|
946
|
-
for (const
|
|
947
|
-
const slug = basename(
|
|
842
|
+
for (const filePath of files) {
|
|
843
|
+
const slug = basename(filePath, '.md');
|
|
948
844
|
allSlugs.add(slug);
|
|
949
845
|
knowledgeSlugs.add(slug);
|
|
950
846
|
let fm;
|
|
951
847
|
try {
|
|
952
|
-
fm = parseFilePath(
|
|
848
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
953
849
|
}
|
|
954
850
|
catch {
|
|
955
851
|
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
@@ -989,6 +885,7 @@ export function createHafez(config) {
|
|
|
989
885
|
throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
|
|
990
886
|
}
|
|
991
887
|
const modifiedFiles = [];
|
|
888
|
+
const deletedFiles = [];
|
|
992
889
|
if (target === 'knowledge') {
|
|
993
890
|
// Cross-kind: move file from entities/ to knowledge/
|
|
994
891
|
const newPath = knowledgePath(slug);
|
|
@@ -1013,8 +910,7 @@ export function createHafez(config) {
|
|
|
1013
910
|
modifiedFiles.push(relative(vaultPath, newPath));
|
|
1014
911
|
// The deletion must be staged too — `git add` of a removed path stages
|
|
1015
912
|
// the removal; omitting it leaves the vault permanently dirty.
|
|
1016
|
-
|
|
1017
|
-
getIndex().removeItem(slug);
|
|
913
|
+
deletedFiles.push(relative(vaultPath, filePath));
|
|
1018
914
|
}
|
|
1019
915
|
else {
|
|
1020
916
|
// Same-kind: capture→entity, capture→project, entity→project
|
|
@@ -1053,7 +949,7 @@ export function createHafez(config) {
|
|
|
1053
949
|
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
1054
950
|
modifiedFiles.push(relative(vaultPath, filePath));
|
|
1055
951
|
}
|
|
1056
|
-
return { modifiedFiles };
|
|
952
|
+
return { modifiedFiles, deletedFiles };
|
|
1057
953
|
}
|
|
1058
954
|
async function promote(slug, target) {
|
|
1059
955
|
if (readOnly)
|
|
@@ -1062,20 +958,24 @@ export function createHafez(config) {
|
|
|
1062
958
|
const filePath = findFile(slug);
|
|
1063
959
|
if (!filePath)
|
|
1064
960
|
throw new HafezError('NOT_FOUND', `'${slug}' not found`);
|
|
1065
|
-
|
|
1066
|
-
// Capture original content for rollback before parsing (avoids double read)
|
|
961
|
+
// Capture original content for rollback
|
|
1067
962
|
const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
|
|
1068
963
|
const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
|
|
1069
|
-
|
|
1070
|
-
const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
964
|
+
let a;
|
|
1071
965
|
try {
|
|
1072
|
-
|
|
1073
|
-
await
|
|
1074
|
-
getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
|
|
1075
|
-
regenerateIndexSafe();
|
|
1076
|
-
onWrite?.(slug, 'promote');
|
|
966
|
+
a = applyOperation({ op: 'promote', slug, target });
|
|
967
|
+
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
1077
968
|
}
|
|
1078
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.
|
|
1079
979
|
if (target === 'knowledge' && originalContent) {
|
|
1080
980
|
writeFileSync(filePath, originalContent);
|
|
1081
981
|
// Never unlink the file we just restored (SEC-4)
|
|
@@ -1084,6 +984,9 @@ export function createHafez(config) {
|
|
|
1084
984
|
}
|
|
1085
985
|
throw err;
|
|
1086
986
|
}
|
|
987
|
+
updateIndexPostCommit([a]);
|
|
988
|
+
regenerateIndexSafe();
|
|
989
|
+
onWrite?.(slug, 'promote');
|
|
1087
990
|
return slug;
|
|
1088
991
|
});
|
|
1089
992
|
}
|
|
@@ -1118,7 +1021,7 @@ export function createHafez(config) {
|
|
|
1118
1021
|
sync: async () => {
|
|
1119
1022
|
if (readOnly)
|
|
1120
1023
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
1121
|
-
return withLock(() =>
|
|
1024
|
+
return withLock(() => journal.sync());
|
|
1122
1025
|
},
|
|
1123
1026
|
children: async (slug) => {
|
|
1124
1027
|
const idx = getIndex();
|
|
@@ -1161,10 +1064,9 @@ export function createHafez(config) {
|
|
|
1161
1064
|
s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
|
|
1162
1065
|
return s;
|
|
1163
1066
|
},
|
|
1164
|
-
changelog: (since) =>
|
|
1067
|
+
changelog: (since) => journal.changelog(since),
|
|
1165
1068
|
};
|
|
1166
1069
|
}
|
|
1167
1070
|
export { HafezError } from './types.js';
|
|
1168
|
-
export { getBrief, getNextActions, findSection } from './
|
|
1169
|
-
export { parseSessionLogHeading, parseSessionLog } from './
|
|
1170
|
-
export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
|
|
1071
|
+
export { getBrief, getNextActions, findSection } from './document.js';
|
|
1072
|
+
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|