hafez 1.1.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/README.md +15 -8
- 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 +1 -109
- package/dist/cli/commands.js +197 -256
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +37 -9
- package/dist/cli/index.js +61 -69
- package/dist/cli/onboard.d.ts +1 -1
- package/dist/cli/onboard.js +7 -1
- 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 +20 -2
- package/dist/contracts.d.ts +6 -0
- package/dist/contracts.js +8 -2
- package/dist/db.js +20 -30
- 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 +13 -7
- package/dist/git.js +79 -21
- package/dist/index.d.ts +3 -4
- package/dist/index.js +508 -406
- 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 +62 -1
- package/dist/vault.d.ts +34 -6
- package/dist/vault.js +71 -7
- package/package.json +4 -4
- package/skill/SKILL.md +7 -1
- 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 } 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
|
|
@@ -71,9 +74,6 @@ export function createHafez(config) {
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
function today() { return new Date().toISOString().slice(0, 10); }
|
|
74
|
-
function kindFromPath(filePath) {
|
|
75
|
-
return filePath.includes('/entities/') ? 'entity' : 'knowledge';
|
|
76
|
-
}
|
|
77
77
|
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
|
|
78
78
|
const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
|
|
79
79
|
function validateKindFields(kind, fields) {
|
|
@@ -100,20 +100,22 @@ export function createHafez(config) {
|
|
|
100
100
|
process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
-
// Shared by create()
|
|
104
|
-
//
|
|
105
|
-
|
|
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) {
|
|
106
108
|
const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
|
|
107
109
|
if (ef.domain?.length)
|
|
108
110
|
fm.domain = ef.domain;
|
|
109
111
|
if (ef.parent) {
|
|
110
|
-
if (!
|
|
112
|
+
if (!existsFn(ef.parent))
|
|
111
113
|
throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
|
|
112
114
|
fm.parent = ef.parent;
|
|
113
115
|
}
|
|
114
116
|
if (ef.related?.length) {
|
|
115
117
|
for (const r of ef.related) {
|
|
116
|
-
if (!
|
|
118
|
+
if (!existsFn(r))
|
|
117
119
|
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
118
120
|
}
|
|
119
121
|
fm.related = ef.related;
|
|
@@ -129,13 +131,25 @@ export function createHafez(config) {
|
|
|
129
131
|
throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
|
|
130
132
|
return fm;
|
|
131
133
|
}
|
|
132
|
-
|
|
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) {
|
|
133
147
|
const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
|
|
134
148
|
if (kf.domain?.length)
|
|
135
149
|
fm.domain = kf.domain;
|
|
136
150
|
if (kf.related?.length) {
|
|
137
151
|
for (const r of kf.related) {
|
|
138
|
-
if (!
|
|
152
|
+
if (!existsFn(r))
|
|
139
153
|
throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
|
|
140
154
|
}
|
|
141
155
|
fm.related = kf.related;
|
|
@@ -153,50 +167,17 @@ export function createHafez(config) {
|
|
|
153
167
|
fm['session-date'] = kf['session-date'];
|
|
154
168
|
return fm;
|
|
155
169
|
}
|
|
156
|
-
function replaceSection(body, heading, content, insertBefore) {
|
|
157
|
-
const idx = body.indexOf(heading);
|
|
158
|
-
if (idx >= 0) {
|
|
159
|
-
const afterPos = idx + heading.length;
|
|
160
|
-
const nextH = findNextStructuralHeading(body, afterPos);
|
|
161
|
-
if (nextH !== -1) {
|
|
162
|
-
return body.slice(0, afterPos) + '\n\n' + content + '\n' + body.slice(nextH);
|
|
163
|
-
}
|
|
164
|
-
return body.slice(0, afterPos) + '\n\n' + content + '\n';
|
|
165
|
-
}
|
|
166
|
-
if (insertBefore) {
|
|
167
|
-
const beforeIdx = body.indexOf(insertBefore);
|
|
168
|
-
if (beforeIdx >= 0) {
|
|
169
|
-
return body.slice(0, beforeIdx) + heading + '\n\n' + content + '\n\n' + body.slice(beforeIdx);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return body + '\n\n' + heading + '\n\n' + content + '\n';
|
|
173
|
-
}
|
|
174
|
-
function insertSessionLog(body, entry) {
|
|
175
|
-
const slHeader = '## Session Log';
|
|
176
|
-
const slIdx = body.indexOf(slHeader);
|
|
177
|
-
if (slIdx >= 0) {
|
|
178
|
-
const insertPos = slIdx + slHeader.length;
|
|
179
|
-
return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
|
|
180
|
-
}
|
|
181
|
-
return body + '\n\n' + slHeader + '\n\n' + entry + '\n';
|
|
182
|
-
}
|
|
183
|
-
function appendToSection(body, heading, content) {
|
|
184
|
-
const idx = body.indexOf(heading);
|
|
185
|
-
if (idx >= 0) {
|
|
186
|
-
const afterPos = idx + heading.length;
|
|
187
|
-
const nextH = findNextStructuralHeading(body, afterPos);
|
|
188
|
-
if (nextH !== -1) {
|
|
189
|
-
return body.slice(0, afterPos) + body.slice(afterPos, nextH) + '\n' + content + body.slice(nextH);
|
|
190
|
-
}
|
|
191
|
-
return body.slice(0, afterPos) + body.slice(afterPos) + '\n' + content + '\n';
|
|
192
|
-
}
|
|
193
|
-
return body + '\n\n' + heading + '\n\n' + content + '\n';
|
|
194
|
-
}
|
|
195
170
|
function applyUpdateFields(fm, body, fields) {
|
|
196
|
-
|
|
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
|
+
}
|
|
197
177
|
fm.status = fields.status;
|
|
178
|
+
}
|
|
198
179
|
if (fields.current_state !== undefined) {
|
|
199
|
-
body =
|
|
180
|
+
body = setSection(body, 'Current State', fields.current_state, 'Session Log');
|
|
200
181
|
}
|
|
201
182
|
if (fields.brief !== undefined) {
|
|
202
183
|
body = fields.brief === null ? removeBrief(body) : setBrief(body, fields.brief);
|
|
@@ -225,7 +206,7 @@ export function createHafez(config) {
|
|
|
225
206
|
const logErrors = validateSessionLogEntry(fields.session_log);
|
|
226
207
|
if (logErrors.length > 0)
|
|
227
208
|
throw new HafezError('VALIDATION_FAILED', 'Invalid session log entry', logErrors);
|
|
228
|
-
body =
|
|
209
|
+
body = prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
|
|
229
210
|
}
|
|
230
211
|
// Domain (shared) + knowledge-only metadata
|
|
231
212
|
if (fields.domain !== undefined) {
|
|
@@ -246,8 +227,12 @@ export function createHafez(config) {
|
|
|
246
227
|
else
|
|
247
228
|
fm.resource = fields.resource;
|
|
248
229
|
}
|
|
249
|
-
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
|
+
}
|
|
250
234
|
fm.confidence = fields.confidence;
|
|
235
|
+
}
|
|
251
236
|
if (fields.tags !== undefined)
|
|
252
237
|
fm.tags = fields.tags;
|
|
253
238
|
if (fields.related !== undefined) {
|
|
@@ -258,18 +243,18 @@ export function createHafez(config) {
|
|
|
258
243
|
fm.related = fields.related;
|
|
259
244
|
}
|
|
260
245
|
if (fields.synthesis !== undefined) {
|
|
261
|
-
body =
|
|
246
|
+
body = setSection(body, 'Synthesis', fields.synthesis);
|
|
262
247
|
}
|
|
263
248
|
if (fields.add_evidence) {
|
|
264
249
|
const sanitized = fields.add_evidence.replace(/^#{1,2}\s/gm, '### ');
|
|
265
|
-
body = appendToSection(body, '
|
|
250
|
+
body = appendToSection(body, 'Evidence', sanitized);
|
|
266
251
|
fm['reinforcement-count'] = (fm['reinforcement-count'] ?? 0) + 1;
|
|
267
252
|
fm['last-reinforced'] = today();
|
|
268
253
|
// NOTE: Confidence auto-promotion (observation→pattern at count>=3) was intentionally
|
|
269
254
|
// removed. Confidence changes are now a deliberate decision via update --confidence.
|
|
270
255
|
}
|
|
271
256
|
if (fields.add_source) {
|
|
272
|
-
body = appendToSection(body, '
|
|
257
|
+
body = appendToSection(body, 'Sources', fields.add_source);
|
|
273
258
|
}
|
|
274
259
|
if (fm['last-touched'] !== today())
|
|
275
260
|
fm['last-touched'] = today();
|
|
@@ -292,141 +277,323 @@ export function createHafez(config) {
|
|
|
292
277
|
if (!filePath)
|
|
293
278
|
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
|
|
294
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;
|
|
295
282
|
const frontmatter = parsed.frontmatter;
|
|
296
283
|
const body = parsed.body;
|
|
297
284
|
if (depth === 'frontmatter')
|
|
298
|
-
return { frontmatter, body: '' };
|
|
285
|
+
return { kind, frontmatter, body: '' };
|
|
299
286
|
if (depth === 'summary') {
|
|
300
|
-
|
|
301
|
-
return { frontmatter, body: sessionLogIdx >= 0 ? body.slice(0, sessionLogIdx).trim() : body };
|
|
287
|
+
return { kind, frontmatter, body: bodyBeforeSessionLog(body) };
|
|
302
288
|
}
|
|
303
|
-
return { frontmatter, body };
|
|
289
|
+
return { kind, frontmatter, body };
|
|
304
290
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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);
|
|
310
412
|
if (exists(slug))
|
|
311
413
|
throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
|
|
312
|
-
if (kind === 'entity') {
|
|
313
|
-
const ef = fields;
|
|
314
|
-
const fm = buildEntityFrontmatter(name, ef);
|
|
315
|
-
let body =
|
|
316
|
-
if (ef.brief)
|
|
317
|
-
body = setBrief(body, ef.brief);
|
|
318
|
-
if (ef.add_action)
|
|
319
|
-
body = addNextAction(body, ef.add_action);
|
|
414
|
+
if (op.kind === 'entity') {
|
|
415
|
+
const ef = op.fields;
|
|
416
|
+
const fm = buildEntityFrontmatter(op.name, ef);
|
|
417
|
+
let body = buildEntityBody(ef);
|
|
320
418
|
const filePath = entityPath(slug);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
324
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `create: ${name}`, gitConfig);
|
|
325
|
-
getIndex().upsertFromFile(slug, 'entity');
|
|
326
|
-
onWrite?.(slug, 'create');
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
const kf = (fields || {});
|
|
330
|
-
const fm = buildKnowledgeFrontmatter(name, kf);
|
|
331
|
-
let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
|
|
332
|
-
const filePath = knowledgePath(slug);
|
|
333
|
-
mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
|
|
419
|
+
captureOriginal(filePath);
|
|
420
|
+
mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
|
|
334
421
|
body = syncRelatedSection(body, fm);
|
|
335
422
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
};
|
|
339
431
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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);
|
|
349
451
|
if (exists(slug))
|
|
350
452
|
throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists`);
|
|
351
453
|
const fm = {
|
|
352
|
-
name,
|
|
454
|
+
name: op.name,
|
|
353
455
|
type: 'capture',
|
|
354
456
|
status: 'active',
|
|
355
457
|
created: today(),
|
|
356
458
|
'last-touched': today(),
|
|
357
459
|
};
|
|
358
|
-
const body = bodyTemplate('capture', { notes });
|
|
359
460
|
const filePath = entityPath(slug);
|
|
360
|
-
|
|
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);
|
|
493
|
+
writeFileSync(filePath, serializeFile(fm, body));
|
|
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);
|
|
361
520
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
362
|
-
|
|
363
|
-
|
|
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');
|
|
364
562
|
regenerateIndexSafe();
|
|
365
|
-
|
|
366
|
-
|
|
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;
|
|
367
576
|
});
|
|
368
577
|
}
|
|
369
578
|
async function update(slug, fields) {
|
|
370
579
|
if (readOnly)
|
|
371
580
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
372
581
|
return withLock(async () => {
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const kind = kindFromPath(filePath);
|
|
377
|
-
validateKindFields(kind, fields);
|
|
378
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
379
|
-
const applied = applyUpdateFields(fm, body, fields);
|
|
380
|
-
fm = applied.fm;
|
|
381
|
-
body = applied.body;
|
|
382
|
-
// Session log archival
|
|
383
|
-
if (countSessionLogEntries(body) >= 10) {
|
|
384
|
-
const extracted = extractOldestSessionLogEntry(body);
|
|
385
|
-
if (extracted) {
|
|
386
|
-
const archiveDir = join(vaultPath, 'entities', 'archive');
|
|
387
|
-
mkdirSync(archiveDir, { recursive: true });
|
|
388
|
-
const archivePath = join(archiveDir, `${slug}-log.md`);
|
|
389
|
-
appendFileSync(archivePath, extracted.entry + '\n\n');
|
|
390
|
-
body = extracted.remaining;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
body = syncRelatedSection(body, fm);
|
|
394
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
395
|
-
const files = [relative(vaultPath, filePath)];
|
|
396
|
-
const archivePath = join(vaultPath, 'entities', 'archive', `${slug}-log.md`);
|
|
397
|
-
if (existsSync(archivePath))
|
|
398
|
-
files.push(relative(vaultPath, archivePath));
|
|
399
|
-
await gitCommitAndPush(vaultPath, files, `update: ${slug}`, gitConfig);
|
|
400
|
-
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]);
|
|
401
585
|
regenerateIndexSafe();
|
|
402
586
|
onWrite?.(slug, 'update');
|
|
403
|
-
return { matched_action:
|
|
587
|
+
return { matched_action: a.matchedAction };
|
|
404
588
|
});
|
|
405
589
|
}
|
|
406
590
|
async function link(slug, target, relation) {
|
|
407
591
|
if (readOnly)
|
|
408
592
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
409
593
|
return withLock(async () => {
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (!exists(target))
|
|
414
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${target}' does not exist`);
|
|
415
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
416
|
-
if (relation === 'parent') {
|
|
417
|
-
fm.parent = target;
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
const related = fm.related || [];
|
|
421
|
-
if (!related.includes(target))
|
|
422
|
-
related.push(target);
|
|
423
|
-
fm.related = related;
|
|
424
|
-
}
|
|
425
|
-
body = syncRelatedSection(body, fm);
|
|
426
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
427
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `link: ${slug} → ${target} (${relation})`, gitConfig);
|
|
428
|
-
const kind = kindFromPath(filePath);
|
|
429
|
-
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]);
|
|
430
597
|
regenerateIndexSafe();
|
|
431
598
|
onWrite?.(slug, 'link');
|
|
432
599
|
});
|
|
@@ -435,31 +602,121 @@ export function createHafez(config) {
|
|
|
435
602
|
if (readOnly)
|
|
436
603
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
437
604
|
return withLock(async () => {
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
442
|
-
if (relation === 'parent' && fm.parent === target) {
|
|
443
|
-
delete fm.parent;
|
|
444
|
-
}
|
|
445
|
-
else if (relation === 'related' && fm.related) {
|
|
446
|
-
fm.related = fm.related.filter((r) => r !== target);
|
|
447
|
-
if (fm.related.length === 0)
|
|
448
|
-
delete fm.related;
|
|
449
|
-
}
|
|
450
|
-
body = syncRelatedSection(body, fm);
|
|
451
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
452
|
-
await gitCommitAndPush(vaultPath, [relative(vaultPath, filePath)], `unlink: ${slug} ✕ ${target} (${relation})`, gitConfig);
|
|
453
|
-
const kind = kindFromPath(filePath);
|
|
454
|
-
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]);
|
|
455
608
|
regenerateIndexSafe();
|
|
456
609
|
onWrite?.(slug, 'unlink');
|
|
457
610
|
});
|
|
458
611
|
}
|
|
612
|
+
// "Recent" with day-granularity dates: primary sort is the frontmatter day,
|
|
613
|
+
// ties break on git commit time (the real recency signal), then name for
|
|
614
|
+
// files with no commit history yet. Without this the top-5 lists leak
|
|
615
|
+
// SQLite row order — a brand-new vault (everything same-day) showed
|
|
616
|
+
// alphabetical-and-missing-newest lists.
|
|
617
|
+
async function rankRecents(rows, dateKey, mode) {
|
|
618
|
+
const day = (r) => r[dateKey];
|
|
619
|
+
if (rows.length <= 1)
|
|
620
|
+
return rows;
|
|
621
|
+
// Everything sharing the 5th row's day (or newer) could land in the top 5
|
|
622
|
+
const cutoffDay = day(rows[Math.min(4, rows.length - 1)]);
|
|
623
|
+
const candidates = rows.filter(r => day(r) >= cutoffDay);
|
|
624
|
+
const hasTies = new Set(candidates.map(day)).size < candidates.length;
|
|
625
|
+
if (!hasTies)
|
|
626
|
+
return rows.slice(0, 5);
|
|
627
|
+
const times = await journal.fileTimes(mode);
|
|
628
|
+
const gitTime = (r) => times.get(`${kindDir('entity')}/${r.slug}.md`) ?? '';
|
|
629
|
+
candidates.sort((a, b) => {
|
|
630
|
+
const dayCmp = day(b).localeCompare(day(a));
|
|
631
|
+
if (dayCmp !== 0)
|
|
632
|
+
return dayCmp;
|
|
633
|
+
const gitCmp = gitTime(b).localeCompare(gitTime(a)); // '' (uncommitted) sorts last
|
|
634
|
+
if (gitCmp !== 0)
|
|
635
|
+
return gitCmp;
|
|
636
|
+
return a.name.localeCompare(b.name);
|
|
637
|
+
});
|
|
638
|
+
return candidates.slice(0, 5);
|
|
639
|
+
}
|
|
640
|
+
// Shared validate phase — dry-run and apply both run this, so dry-run can
|
|
641
|
+
// never pass a payload apply rejects (parity by construction). Simulates
|
|
642
|
+
// creates so same-batch references validate: batch executes sequentially,
|
|
643
|
+
// so op N really does see op 1's creates on disk at apply time.
|
|
644
|
+
function validateBatchCore(operations) {
|
|
645
|
+
// slug → what it would be after earlier ops in this batch
|
|
646
|
+
const simulated = new Map();
|
|
647
|
+
const simulatedSessions = new Set();
|
|
648
|
+
const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
|
|
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' };
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return { kind, type: 'entity' };
|
|
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;
|
|
694
|
+
}
|
|
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)
|
|
699
|
+
entry.created = true;
|
|
700
|
+
if (res.warning)
|
|
701
|
+
entry.warning = res.warning;
|
|
702
|
+
report.push(entry);
|
|
703
|
+
}
|
|
704
|
+
return { valid: report.every(o => o.errors.length === 0), operations: report };
|
|
705
|
+
}
|
|
706
|
+
function formatOpErrors(report) {
|
|
707
|
+
return report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
|
|
708
|
+
}
|
|
459
709
|
async function batch(operations) {
|
|
460
710
|
if (readOnly)
|
|
461
711
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
462
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
|
+
}
|
|
463
720
|
// Capture originals for rollback
|
|
464
721
|
const originals = new Map();
|
|
465
722
|
function captureOriginal(filePath) {
|
|
@@ -467,224 +724,59 @@ export function createHafez(config) {
|
|
|
467
724
|
originals.set(filePath, existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null);
|
|
468
725
|
}
|
|
469
726
|
}
|
|
470
|
-
const
|
|
471
|
-
const affectedSlugs = [];
|
|
727
|
+
const appliedOps = [];
|
|
472
728
|
const results = [];
|
|
729
|
+
let currentOp = -1;
|
|
473
730
|
try {
|
|
474
731
|
for (const op of operations) {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
validateKindFields(kindFromPath(filePath), op.fields);
|
|
481
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
482
|
-
const applied = applyUpdateFields(fm, body, op.fields);
|
|
483
|
-
applied.body = syncRelatedSection(applied.body, applied.fm);
|
|
484
|
-
writeFileSync(filePath, serializeFile(applied.fm, applied.body));
|
|
485
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
486
|
-
affectedSlugs.push(op.slug);
|
|
487
|
-
results.push({ op: 'update', slug: op.slug, status: 'ok' });
|
|
488
|
-
}
|
|
489
|
-
else if (op.op === 'create' && op.kind === 'session') {
|
|
490
|
-
const slug = slugify(op.name);
|
|
491
|
-
if (exists(slug)) {
|
|
492
|
-
results.push({ op: 'create', slug, status: 'error', error: `Slug '${slug}' already exists in the vault` });
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
const sessionDir = join(vaultPath, 'sessions');
|
|
496
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
497
|
-
const filePath = join(sessionDir, `${slug}.md`);
|
|
498
|
-
if (existsSync(filePath)) {
|
|
499
|
-
results.push({ op: 'create', slug, status: 'error', error: `Session '${slug}' already exists` });
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
captureOriginal(filePath);
|
|
503
|
-
const fm = { name: op.name, created: today() };
|
|
504
|
-
const kf = (op.fields ?? {});
|
|
505
|
-
if (kf['session-date'])
|
|
506
|
-
fm['session-date'] = kf['session-date'];
|
|
507
|
-
if (kf.related?.length)
|
|
508
|
-
fm.related = kf.related;
|
|
509
|
-
// Session files use sections: Summary, Entities Touched, Decisions
|
|
510
|
-
const sections = ['Summary', 'Entities Touched', 'Decisions'];
|
|
511
|
-
const lines = [];
|
|
512
|
-
for (const section of sections) {
|
|
513
|
-
lines.push(`## ${section}`);
|
|
514
|
-
if (section === 'Summary' && kf.synthesis)
|
|
515
|
-
lines.push('', kf.synthesis);
|
|
516
|
-
lines.push('');
|
|
517
|
-
}
|
|
518
|
-
let sessionBody = lines.join('\n');
|
|
519
|
-
sessionBody = syncRelatedSection(sessionBody, fm);
|
|
520
|
-
writeFileSync(filePath, serializeFile(fm, sessionBody));
|
|
521
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
522
|
-
// Sessions are not indexed (db.ts only scans entities/ and knowledge/).
|
|
523
|
-
// They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
|
|
524
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
525
|
-
}
|
|
526
|
-
else if (op.op === 'create') {
|
|
527
|
-
const slug = slugify(op.name);
|
|
528
|
-
if (exists(slug))
|
|
529
|
-
throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
|
|
530
|
-
if (op.kind === 'entity') {
|
|
531
|
-
const ef = op.fields;
|
|
532
|
-
const fm = buildEntityFrontmatter(op.name, ef);
|
|
533
|
-
let entBody = bodyTemplate(ef.type, { purpose: ef.purpose });
|
|
534
|
-
if (ef.brief)
|
|
535
|
-
entBody = setBrief(entBody, ef.brief);
|
|
536
|
-
if (ef.add_action)
|
|
537
|
-
entBody = addNextAction(entBody, ef.add_action);
|
|
538
|
-
const filePath = entityPath(slug);
|
|
539
|
-
captureOriginal(filePath);
|
|
540
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
541
|
-
entBody = syncRelatedSection(entBody, fm);
|
|
542
|
-
writeFileSync(filePath, serializeFile(fm, entBody));
|
|
543
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
544
|
-
affectedSlugs.push(slug);
|
|
545
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
const kf = (op.fields || {});
|
|
549
|
-
const fm = buildKnowledgeFrontmatter(op.name, kf);
|
|
550
|
-
let body = knowledgeBodyTemplate(kf.subtype, { synthesis: kf.synthesis });
|
|
551
|
-
const filePath = knowledgePath(slug);
|
|
552
|
-
captureOriginal(filePath);
|
|
553
|
-
mkdirSync(join(vaultPath, 'knowledge'), { recursive: true });
|
|
554
|
-
body = syncRelatedSection(body, fm);
|
|
555
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
556
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
557
|
-
affectedSlugs.push(slug);
|
|
558
|
-
results.push({ op: 'create', slug, status: 'ok', created: true });
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
else if (op.op === 'capture') {
|
|
562
|
-
const slug = slugify(op.name);
|
|
563
|
-
if (exists(slug))
|
|
564
|
-
throw new HafezError('VALIDATION_FAILED', `Slug '${slug}' already exists`);
|
|
565
|
-
const fm = { name: op.name, type: 'capture', status: 'active', created: today(), 'last-touched': today() };
|
|
566
|
-
const filePath = entityPath(slug);
|
|
567
|
-
captureOriginal(filePath);
|
|
568
|
-
mkdirSync(join(vaultPath, 'entities'), { recursive: true });
|
|
569
|
-
writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
|
|
570
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
571
|
-
affectedSlugs.push(slug);
|
|
572
|
-
results.push({ op: 'capture', slug, status: 'ok', created: true });
|
|
573
|
-
}
|
|
574
|
-
else if (op.op === 'link') {
|
|
575
|
-
const filePath = findFile(op.slug);
|
|
576
|
-
if (!filePath)
|
|
577
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
578
|
-
if (!exists(op.target))
|
|
579
|
-
throw new HafezError('VALIDATION_FAILED', `Target '${op.target}' does not exist`);
|
|
580
|
-
captureOriginal(filePath);
|
|
581
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
582
|
-
if (op.relation === 'parent') {
|
|
583
|
-
fm.parent = op.target;
|
|
584
|
-
}
|
|
585
|
-
else {
|
|
586
|
-
const related = fm.related || [];
|
|
587
|
-
if (!related.includes(op.target))
|
|
588
|
-
related.push(op.target);
|
|
589
|
-
fm.related = related;
|
|
590
|
-
}
|
|
591
|
-
body = syncRelatedSection(body, fm);
|
|
592
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
593
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
594
|
-
affectedSlugs.push(op.slug);
|
|
595
|
-
results.push({ op: 'link', slug: op.slug, status: 'ok' });
|
|
596
|
-
}
|
|
597
|
-
else if (op.op === 'unlink') {
|
|
598
|
-
const filePath = findFile(op.slug);
|
|
599
|
-
if (!filePath)
|
|
600
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
601
|
-
captureOriginal(filePath);
|
|
602
|
-
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
603
|
-
if (op.relation === 'parent' && fm.parent === op.target)
|
|
604
|
-
delete fm.parent;
|
|
605
|
-
else if (op.relation === 'related' && fm.related) {
|
|
606
|
-
fm.related = fm.related.filter((r) => r !== op.target);
|
|
607
|
-
if (fm.related.length === 0)
|
|
608
|
-
delete fm.related;
|
|
609
|
-
}
|
|
610
|
-
body = syncRelatedSection(body, fm);
|
|
611
|
-
writeFileSync(filePath, serializeFile(fm, body));
|
|
612
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
613
|
-
affectedSlugs.push(op.slug);
|
|
614
|
-
results.push({ op: 'unlink', slug: op.slug, status: 'ok' });
|
|
615
|
-
}
|
|
616
|
-
else if (op.op === 'promote') {
|
|
617
|
-
const filePath = findFile(op.slug);
|
|
618
|
-
if (!filePath)
|
|
619
|
-
throw new HafezError('VALIDATION_FAILED', `'${op.slug}' not found`);
|
|
620
|
-
captureOriginal(filePath);
|
|
621
|
-
if (op.target === 'knowledge') {
|
|
622
|
-
captureOriginal(knowledgePath(op.slug));
|
|
623
|
-
}
|
|
624
|
-
const kind = kindFromPath(filePath);
|
|
625
|
-
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
626
|
-
const { modifiedFiles: promoteFiles } = promoteCore(op.slug, op.target, filePath, fm, body, kind);
|
|
627
|
-
modifiedFiles.push(...promoteFiles);
|
|
628
|
-
affectedSlugs.push(op.slug);
|
|
629
|
-
results.push({ op: 'promote', slug: op.slug, status: 'ok' });
|
|
630
|
-
}
|
|
732
|
+
currentOp++;
|
|
733
|
+
const a = applyOperation(op, captureOriginal);
|
|
734
|
+
results.push(a.result);
|
|
735
|
+
if (a.result.status === 'ok')
|
|
736
|
+
appliedOps.push(a);
|
|
631
737
|
}
|
|
632
738
|
}
|
|
633
739
|
catch (err) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
writeFileSync(path, content);
|
|
644
|
-
}
|
|
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);
|
|
645
748
|
}
|
|
646
749
|
throw err;
|
|
647
750
|
}
|
|
648
|
-
// Single
|
|
649
|
-
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))];
|
|
650
754
|
const indexAfterBatch = () => {
|
|
651
|
-
|
|
652
|
-
const fp = findFile(slug);
|
|
653
|
-
if (fp) {
|
|
654
|
-
getIndex().upsertFromFile(slug, kindFromPath(fp));
|
|
655
|
-
}
|
|
656
|
-
}
|
|
755
|
+
updateIndexPostCommit(appliedOps);
|
|
657
756
|
// Regenerate vault index if any entity or knowledge was touched (session-only batches skip)
|
|
658
|
-
const anyIndexedTouched =
|
|
757
|
+
const anyIndexedTouched = [...writtenAll, ...deletedAll].some(isIndexedRelPath);
|
|
659
758
|
if (anyIndexedTouched) {
|
|
660
759
|
regenerateIndexSafe();
|
|
661
760
|
}
|
|
662
|
-
|
|
663
|
-
|
|
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
|
+
}
|
|
664
767
|
};
|
|
665
|
-
if (
|
|
768
|
+
if (writtenAll.length + deletedAll.length > 0) {
|
|
666
769
|
try {
|
|
667
|
-
await
|
|
770
|
+
await journal.commit(writtenAll, deletedAll, `batch: ${operations.length} operations`);
|
|
668
771
|
}
|
|
669
772
|
catch (err) {
|
|
670
773
|
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
671
774
|
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
672
|
-
// committed — restore the captured originals so files,
|
|
673
|
-
// index stay consistent instead of leaving the batch
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
unlinkSync(path);
|
|
678
|
-
}
|
|
679
|
-
catch { }
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
writeFileSync(path, content);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
// Also clear anything the failed commit left staged, or the next
|
|
686
|
-
// commit would publish the rolled-back content.
|
|
687
|
-
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);
|
|
688
780
|
throw err;
|
|
689
781
|
}
|
|
690
782
|
// Push-stage failure: the batch commit exists locally ("changes
|
|
@@ -710,16 +802,16 @@ export function createHafez(config) {
|
|
|
710
802
|
const knowledgeSlugs = new Set();
|
|
711
803
|
const referencedKnowledge = new Set();
|
|
712
804
|
// Scan entities
|
|
713
|
-
const entDir = join(vaultPath, '
|
|
714
|
-
|
|
715
|
-
const files =
|
|
805
|
+
const entDir = join(vaultPath, kindDir('entity'));
|
|
806
|
+
{
|
|
807
|
+
const files = scanVaultDir(entDir);
|
|
716
808
|
report.total_entities = files.length;
|
|
717
|
-
for (const
|
|
718
|
-
const slug = basename(
|
|
809
|
+
for (const filePath of files) {
|
|
810
|
+
const slug = basename(filePath, '.md');
|
|
719
811
|
allSlugs.add(slug);
|
|
720
812
|
let fm;
|
|
721
813
|
try {
|
|
722
|
-
fm = parseFilePath(
|
|
814
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
723
815
|
}
|
|
724
816
|
catch {
|
|
725
817
|
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
@@ -743,17 +835,17 @@ export function createHafez(config) {
|
|
|
743
835
|
}
|
|
744
836
|
}
|
|
745
837
|
// Scan knowledge
|
|
746
|
-
const knDir = join(vaultPath, 'knowledge');
|
|
747
|
-
|
|
748
|
-
const files =
|
|
838
|
+
const knDir = join(vaultPath, kindDir('knowledge'));
|
|
839
|
+
{
|
|
840
|
+
const files = scanVaultDir(knDir);
|
|
749
841
|
report.total_knowledge = files.length;
|
|
750
|
-
for (const
|
|
751
|
-
const slug = basename(
|
|
842
|
+
for (const filePath of files) {
|
|
843
|
+
const slug = basename(filePath, '.md');
|
|
752
844
|
allSlugs.add(slug);
|
|
753
845
|
knowledgeSlugs.add(slug);
|
|
754
846
|
let fm;
|
|
755
847
|
try {
|
|
756
|
-
fm = parseFilePath(
|
|
848
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
757
849
|
}
|
|
758
850
|
catch {
|
|
759
851
|
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
@@ -793,6 +885,7 @@ export function createHafez(config) {
|
|
|
793
885
|
throw new HafezError('VALIDATION_FAILED', `Cannot promote ${currentType} to ${target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
|
|
794
886
|
}
|
|
795
887
|
const modifiedFiles = [];
|
|
888
|
+
const deletedFiles = [];
|
|
796
889
|
if (target === 'knowledge') {
|
|
797
890
|
// Cross-kind: move file from entities/ to knowledge/
|
|
798
891
|
const newPath = knowledgePath(slug);
|
|
@@ -817,8 +910,7 @@ export function createHafez(config) {
|
|
|
817
910
|
modifiedFiles.push(relative(vaultPath, newPath));
|
|
818
911
|
// The deletion must be staged too — `git add` of a removed path stages
|
|
819
912
|
// the removal; omitting it leaves the vault permanently dirty.
|
|
820
|
-
|
|
821
|
-
getIndex().removeItem(slug);
|
|
913
|
+
deletedFiles.push(relative(vaultPath, filePath));
|
|
822
914
|
}
|
|
823
915
|
else {
|
|
824
916
|
// Same-kind: capture→entity, capture→project, entity→project
|
|
@@ -857,7 +949,7 @@ export function createHafez(config) {
|
|
|
857
949
|
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
858
950
|
modifiedFiles.push(relative(vaultPath, filePath));
|
|
859
951
|
}
|
|
860
|
-
return { modifiedFiles };
|
|
952
|
+
return { modifiedFiles, deletedFiles };
|
|
861
953
|
}
|
|
862
954
|
async function promote(slug, target) {
|
|
863
955
|
if (readOnly)
|
|
@@ -866,20 +958,24 @@ export function createHafez(config) {
|
|
|
866
958
|
const filePath = findFile(slug);
|
|
867
959
|
if (!filePath)
|
|
868
960
|
throw new HafezError('NOT_FOUND', `'${slug}' not found`);
|
|
869
|
-
|
|
870
|
-
// Capture original content for rollback before parsing (avoids double read)
|
|
961
|
+
// Capture original content for rollback
|
|
871
962
|
const originalContent = target === 'knowledge' ? readFileSync(filePath, 'utf-8') : null;
|
|
872
963
|
const knowledgeTarget = target === 'knowledge' ? knowledgePath(slug) : null;
|
|
873
|
-
|
|
874
|
-
const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
964
|
+
let a;
|
|
875
965
|
try {
|
|
876
|
-
|
|
877
|
-
await
|
|
878
|
-
getIndex().upsertFromFile(slug, target === 'knowledge' ? 'knowledge' : 'entity');
|
|
879
|
-
regenerateIndexSafe();
|
|
880
|
-
onWrite?.(slug, 'promote');
|
|
966
|
+
a = applyOperation({ op: 'promote', slug, target });
|
|
967
|
+
await journal.commit(a.filesWritten, a.filesDeleted, a.commitMessage);
|
|
881
968
|
}
|
|
882
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.
|
|
883
979
|
if (target === 'knowledge' && originalContent) {
|
|
884
980
|
writeFileSync(filePath, originalContent);
|
|
885
981
|
// Never unlink the file we just restored (SEC-4)
|
|
@@ -888,6 +984,9 @@ export function createHafez(config) {
|
|
|
888
984
|
}
|
|
889
985
|
throw err;
|
|
890
986
|
}
|
|
987
|
+
updateIndexPostCommit([a]);
|
|
988
|
+
regenerateIndexSafe();
|
|
989
|
+
onWrite?.(slug, 'promote');
|
|
891
990
|
return slug;
|
|
892
991
|
});
|
|
893
992
|
}
|
|
@@ -918,10 +1017,11 @@ export function createHafez(config) {
|
|
|
918
1017
|
link,
|
|
919
1018
|
unlink,
|
|
920
1019
|
batch,
|
|
1020
|
+
validateBatch: async (operations) => validateBatchCore(operations),
|
|
921
1021
|
sync: async () => {
|
|
922
1022
|
if (readOnly)
|
|
923
1023
|
throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
|
|
924
|
-
return withLock(() =>
|
|
1024
|
+
return withLock(() => journal.sync());
|
|
925
1025
|
},
|
|
926
1026
|
children: async (slug) => {
|
|
927
1027
|
const idx = getIndex();
|
|
@@ -959,12 +1059,14 @@ export function createHafez(config) {
|
|
|
959
1059
|
if (!idx)
|
|
960
1060
|
return emptyStats;
|
|
961
1061
|
idx.syncIfStale();
|
|
962
|
-
|
|
1062
|
+
const s = idx.getStats();
|
|
1063
|
+
s.recently_touched = await rankRecents(s.recently_touched, 'last_touched', 'modified');
|
|
1064
|
+
s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
|
|
1065
|
+
return s;
|
|
963
1066
|
},
|
|
964
|
-
changelog: (since) =>
|
|
1067
|
+
changelog: (since) => journal.changelog(since),
|
|
965
1068
|
};
|
|
966
1069
|
}
|
|
967
1070
|
export { HafezError } from './types.js';
|
|
968
|
-
export { getBrief, getNextActions, findSection } from './
|
|
969
|
-
export { parseSessionLogHeading, parseSessionLog } from './
|
|
970
|
-
export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
|
|
1071
|
+
export { getBrief, getNextActions, findSection } from './document.js';
|
|
1072
|
+
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|