hafez 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +296 -13
- package/dist/batch-ops.js +186 -62
- package/dist/cli/args.d.ts +5 -1
- package/dist/cli/args.js +6 -2
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +197 -115
- package/dist/cli/help-agent.js +24 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +150 -130
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/contracts.d.ts +49 -6
- package/dist/contracts.js +104 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +309 -33
- package/dist/document.d.ts +97 -88
- package/dist/document.js +512 -382
- package/dist/export-okf.js +41 -15
- package/dist/git.js +165 -48
- package/dist/index.d.ts +2 -2
- package/dist/index.js +212 -727
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +96 -64
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +38 -11
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +50 -4
- package/package.json +1 -1
- package/skill/SKILL.md +51 -110
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
package/dist/export-okf.js
CHANGED
|
@@ -8,18 +8,44 @@
|
|
|
8
8
|
import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
9
9
|
import { join, resolve, sep, basename } from 'path';
|
|
10
10
|
import { parseFilePath, serializeFile, scanVaultDir, kindDir } from './vault.js';
|
|
11
|
+
import { DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
|
|
11
12
|
import { deriveSummary, WIKI_LINK_RE } from './document.js';
|
|
12
13
|
const SOURCE_KINDS = ['entity', 'knowledge', 'session'];
|
|
13
14
|
const KIND_DIRS = SOURCE_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
|
|
14
15
|
// The shared wiki-link syntax (document.ts) — group 1 slug, group 2 display.
|
|
15
16
|
const OKF_LINK_RE = new RegExp(WIKI_LINK_RE.source, 'g');
|
|
17
|
+
/** `index.md` is reserved in every bundle directory for the generated index, so
|
|
18
|
+
* a vault doc slugged `index` cannot keep its name — before this it was silently
|
|
19
|
+
* overwritten by the index that ran after it. Deterministic rule: rename to
|
|
20
|
+
* `index-doc`, then `index-doc-2`, `index-doc-3`, … until the name is free among
|
|
21
|
+
* that directory's real slugs (a vault may legitimately hold both `index` and
|
|
22
|
+
* `index-doc`). Same vault contents always produce the same bundle names, and
|
|
23
|
+
* every link/index reference is written against the renamed file. */
|
|
24
|
+
const RESERVED_INDEX = 'index';
|
|
25
|
+
function assignBundleNames(docs) {
|
|
26
|
+
const slugsByDir = new Map();
|
|
27
|
+
for (const doc of docs) {
|
|
28
|
+
const slugs = slugsByDir.get(doc.dir) ?? new Set();
|
|
29
|
+
slugs.add(doc.slug);
|
|
30
|
+
slugsByDir.set(doc.dir, slugs);
|
|
31
|
+
}
|
|
32
|
+
for (const doc of docs) {
|
|
33
|
+
if (doc.slug !== RESERVED_INDEX)
|
|
34
|
+
continue;
|
|
35
|
+
const taken = slugsByDir.get(doc.dir);
|
|
36
|
+
let candidate = `${RESERVED_INDEX}-doc`;
|
|
37
|
+
for (let n = 2; taken.has(candidate); n++)
|
|
38
|
+
candidate = `${RESERVED_INDEX}-doc-${n}`;
|
|
39
|
+
doc.file = candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
16
42
|
function mapFrontmatter(doc) {
|
|
17
43
|
const { fm, kind, body } = doc;
|
|
18
44
|
const mapped = {};
|
|
19
45
|
if (kind === 'entity')
|
|
20
46
|
mapped.type = fm.type ?? 'entity';
|
|
21
47
|
else if (kind === 'knowledge')
|
|
22
|
-
mapped.type =
|
|
48
|
+
mapped.type = `knowledge/${fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}`;
|
|
23
49
|
else
|
|
24
50
|
mapped.type = 'session';
|
|
25
51
|
mapped.title = fm.name;
|
|
@@ -95,28 +121,28 @@ export function exportOkf(vaultPath, outDir) {
|
|
|
95
121
|
process.stderr.write(`Warning: skipping ${relFile}: missing name\n`);
|
|
96
122
|
continue;
|
|
97
123
|
}
|
|
98
|
-
docs.push({ slug, dir, kind, fm, body });
|
|
124
|
+
docs.push({ slug, dir, kind, fm, body, file: slug });
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
127
|
+
assignBundleNames(docs);
|
|
128
|
+
// Slug→bundle path map: insertion order entities/knowledge/sessions, first write
|
|
129
|
+
// wins, giving entity precedence on cross-kind slug collisions. Paths are
|
|
130
|
+
// bundle-root-absolute with hardcoded '/' separators.
|
|
131
|
+
const slugPaths = new Map();
|
|
104
132
|
for (const doc of docs) {
|
|
105
|
-
if (!
|
|
106
|
-
|
|
133
|
+
if (!slugPaths.has(doc.slug))
|
|
134
|
+
slugPaths.set(doc.slug, `/${doc.dir}/${doc.file}.md`);
|
|
107
135
|
}
|
|
108
136
|
function transformBody(body) {
|
|
109
137
|
return body.replace(OKF_LINK_RE, (_m, rawSlug, rawDisplay) => {
|
|
110
|
-
// Trim: Obsidian-idiomatic [[ foo ]] / [[foo | Label]] carry whitespace in the captures
|
|
111
138
|
const slug = rawSlug.trim();
|
|
112
139
|
const label = rawDisplay?.trim() || slug;
|
|
113
|
-
const
|
|
114
|
-
if (!
|
|
140
|
+
const path = slugPaths.get(slug);
|
|
141
|
+
if (!path) {
|
|
115
142
|
report.unresolvedLinks++;
|
|
116
143
|
return label;
|
|
117
144
|
}
|
|
118
|
-
|
|
119
|
-
return `[${label}](/${dir}/${slug}.md)`;
|
|
145
|
+
return `[${label}](${path})`;
|
|
120
146
|
});
|
|
121
147
|
}
|
|
122
148
|
mkdirSync(resolvedOut, { recursive: true });
|
|
@@ -125,7 +151,7 @@ export function exportOkf(vaultPath, outDir) {
|
|
|
125
151
|
const mapped = mapFrontmatter(doc);
|
|
126
152
|
const outSubdir = join(resolvedOut, doc.dir);
|
|
127
153
|
mkdirSync(outSubdir, { recursive: true });
|
|
128
|
-
writeFileSync(join(outSubdir, `${doc.
|
|
154
|
+
writeFileSync(join(outSubdir, `${doc.file}.md`), serializeFile(mapped, transformBody(doc.body)));
|
|
129
155
|
if (doc.kind === 'entity')
|
|
130
156
|
report.entities++;
|
|
131
157
|
else if (doc.kind === 'knowledge')
|
|
@@ -133,7 +159,7 @@ export function exportOkf(vaultPath, outDir) {
|
|
|
133
159
|
else
|
|
134
160
|
report.sessions++;
|
|
135
161
|
const list = byDir.get(doc.dir) ?? [];
|
|
136
|
-
list.push({
|
|
162
|
+
list.push({ file: doc.file, title: mapped.title, description: mapped.description ?? '' });
|
|
137
163
|
byDir.set(doc.dir, list);
|
|
138
164
|
}
|
|
139
165
|
// Per-dir index.md — reserved file: NO frontmatter, sorted bullets
|
|
@@ -142,7 +168,7 @@ export function exportOkf(vaultPath, outDir) {
|
|
|
142
168
|
const lines = [`# ${heading}`, ''];
|
|
143
169
|
for (const item of items.sort((a, b) => a.title.localeCompare(b.title))) {
|
|
144
170
|
const desc = item.description ? ` — ${item.description}` : '';
|
|
145
|
-
lines.push(`- [${item.title}](/${dir}/${item.
|
|
171
|
+
lines.push(`- [${item.title}](/${dir}/${item.file}.md)${desc}`);
|
|
146
172
|
}
|
|
147
173
|
writeFileSync(join(resolvedOut, dir, 'index.md'), lines.join('\n') + '\n');
|
|
148
174
|
}
|
package/dist/git.js
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { simpleGit } from 'simple-git';
|
|
5
5
|
import { mergeVaultContent } from './merge.js';
|
|
6
|
-
import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath } from './vault.js';
|
|
6
|
+
import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath, vaultKindFromRelPath } from './vault.js';
|
|
7
|
+
import { normalizeSinceDate } from './dates.js';
|
|
7
8
|
import { HafezError } from './types.js';
|
|
8
9
|
const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
|
|
9
10
|
const MAX_PUSH_RETRIES = 3;
|
|
@@ -50,6 +51,61 @@ async function hasOriginRemote(git) {
|
|
|
50
51
|
const remotes = await git.getRemotes();
|
|
51
52
|
return remotes.some(r => r.name === 'origin');
|
|
52
53
|
}
|
|
54
|
+
/** The branch this vault is on (symbolic HEAD). */
|
|
55
|
+
async function currentBranch(git) {
|
|
56
|
+
return (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
57
|
+
}
|
|
58
|
+
/** The branch's configured upstream (`origin/main`), or null when it has none. */
|
|
59
|
+
async function upstreamRef(git) {
|
|
60
|
+
try {
|
|
61
|
+
const ref = (await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim();
|
|
62
|
+
return ref || null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Give the current branch an upstream when git does not know about one yet.
|
|
70
|
+
*
|
|
71
|
+
* `git remote add origin …` on a vault that has only ever been local leaves
|
|
72
|
+
* the branch both unpublished and untracked, and in that state `git pull`,
|
|
73
|
+
* `git push` and `@{u}` are all fatal — so every mutation after the remote
|
|
74
|
+
* was added died at the sync/push stage. Returns false when origin does not
|
|
75
|
+
* carry the branch at all: there is nothing to pull and nothing to track
|
|
76
|
+
* until the first push creates it (see pushCurrent).
|
|
77
|
+
*
|
|
78
|
+
* A remote we cannot reach also reports false — the caller's push, and its
|
|
79
|
+
* retry loop, is where a network failure belongs.
|
|
80
|
+
*/
|
|
81
|
+
async function ensureUpstream(git) {
|
|
82
|
+
if (await upstreamRef(git))
|
|
83
|
+
return true;
|
|
84
|
+
try {
|
|
85
|
+
const branch = await currentBranch(git);
|
|
86
|
+
const heads = await git.raw(['ls-remote', '--heads', 'origin', branch]);
|
|
87
|
+
if (!heads.trim())
|
|
88
|
+
return false;
|
|
89
|
+
await git.fetch('origin', branch);
|
|
90
|
+
await git.raw(['branch', `--set-upstream-to=origin/${branch}`, branch]);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Push the current branch, publishing it when it has no upstream yet. That
|
|
99
|
+
* first `push --set-upstream` is what turns a freshly-added origin into a
|
|
100
|
+
* normal tracking setup; every push after it is the plain push.
|
|
101
|
+
*/
|
|
102
|
+
async function pushCurrent(git) {
|
|
103
|
+
if (await upstreamRef(git)) {
|
|
104
|
+
await git.push();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await git.push(['--set-upstream', 'origin', await currentBranch(git)]);
|
|
108
|
+
}
|
|
53
109
|
/** Read one side of a conflicted file from the index; null if that side deleted it. */
|
|
54
110
|
async function showStage(git, stage, file) {
|
|
55
111
|
try {
|
|
@@ -104,9 +160,12 @@ async function resolveRebaseConflicts(git, vaultPath) {
|
|
|
104
160
|
await git.raw(['rm', '--force', '--ignore-unmatch', '--', file]);
|
|
105
161
|
continue;
|
|
106
162
|
}
|
|
163
|
+
// Classify from the repo-relative path: every file here passed
|
|
164
|
+
// VAULT_FILE_RE, so the Layout classifier is total over it — no
|
|
165
|
+
// fallback kind to drift when a vault dir is added.
|
|
107
166
|
const merged = localContent === null ? remoteContent
|
|
108
167
|
: remoteContent === null ? localContent
|
|
109
|
-
: mergeVaultContent(remoteContent, localContent);
|
|
168
|
+
: mergeVaultContent(remoteContent, localContent, vaultKindFromRelPath(file));
|
|
110
169
|
const fullPath = join(vaultPath, file);
|
|
111
170
|
mkdirSync(dirname(fullPath), { recursive: true });
|
|
112
171
|
writeFileSync(fullPath, merged);
|
|
@@ -188,10 +247,13 @@ async function pullResolving(git, vaultPath, conflictMessage) {
|
|
|
188
247
|
* local data survives a conflict.
|
|
189
248
|
*/
|
|
190
249
|
async function pushResolving(git, vaultPath, conflictMessage, exhaustedMessage) {
|
|
250
|
+
// Bootstrap a freshly-added origin before the retry loop: without an
|
|
251
|
+
// upstream the rejection handling below (pull --rebase) is itself fatal.
|
|
252
|
+
await ensureUpstream(git);
|
|
191
253
|
let lastDetail = '';
|
|
192
254
|
for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
|
|
193
255
|
try {
|
|
194
|
-
await git
|
|
256
|
+
await pushCurrent(git);
|
|
195
257
|
return;
|
|
196
258
|
}
|
|
197
259
|
catch (pushErr) {
|
|
@@ -226,29 +288,36 @@ export async function gitSync(vaultPath) {
|
|
|
226
288
|
await ensureCleanState(git, vaultPath);
|
|
227
289
|
const syncConflictMessage = (files) => `Git conflict during sync (files: ${files.join(', ')}). Changes saved locally.`;
|
|
228
290
|
let pulled = false;
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
291
|
+
// A remote that does not carry this branch yet (freshly added to a vault
|
|
292
|
+
// that has only ever been local) has nothing to pull and no `@{u}` to count
|
|
293
|
+
// against — both steps would be fatal. Skip straight to the push, which
|
|
294
|
+
// publishes the branch and sets the upstream.
|
|
295
|
+
const tracking = await ensureUpstream(git);
|
|
296
|
+
if (tracking) {
|
|
297
|
+
// Always pull first (bidirectional sync)
|
|
298
|
+
const headBefore = (await git.revparse(['HEAD'])).trim();
|
|
299
|
+
for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
|
|
300
|
+
try {
|
|
301
|
+
await pullResolving(git, vaultPath, syncConflictMessage);
|
|
302
|
+
const headAfter = (await git.revparse(['HEAD'])).trim();
|
|
303
|
+
pulled = headAfter !== headBefore;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
if (err instanceof HafezError)
|
|
308
|
+
throw err;
|
|
309
|
+
if (attempt === MAX_PUSH_RETRIES) {
|
|
310
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
311
|
+
throw new HafezError('GIT_PUSH_FAILED', `Git pull failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
|
|
312
|
+
}
|
|
313
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
|
|
244
314
|
}
|
|
245
|
-
await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
|
|
246
315
|
}
|
|
316
|
+
// Check if there are local commits to push
|
|
317
|
+
const ahead = await git.raw(['rev-list', '--count', '@{u}..HEAD']).then(s => parseInt(s.trim(), 10));
|
|
318
|
+
if (ahead === 0)
|
|
319
|
+
return { pulled, pushed: false, remote: true };
|
|
247
320
|
}
|
|
248
|
-
// Check if there are local commits to push
|
|
249
|
-
const ahead = await git.raw(['rev-list', '--count', '@{u}..HEAD']).then(s => parseInt(s.trim(), 10));
|
|
250
|
-
if (ahead === 0)
|
|
251
|
-
return { pulled, pushed: false, remote: true };
|
|
252
321
|
await pushResolving(git, vaultPath, syncConflictMessage, `Git push failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`);
|
|
253
322
|
return { pulled, pushed: true, remote: true };
|
|
254
323
|
}
|
|
@@ -265,6 +334,23 @@ async function gitUnstage(vaultPath, files) {
|
|
|
265
334
|
}
|
|
266
335
|
catch { /* best effort — rollback must not mask the original error */ }
|
|
267
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* The subset of `files` that `git add` can actually stage. `git add` is fatal
|
|
339
|
+
* on a pathspec matching nothing, and a batch that creates then deletes the
|
|
340
|
+
* same slug (capture then cross-kind promote) hands the Journal a deleted path
|
|
341
|
+
* that is absent from the worktree AND was never tracked — unstageable by
|
|
342
|
+
* construction, and it needs no staging: git never knew about it. A deleted
|
|
343
|
+
* path git DOES track still goes through `add`, which stages its removal.
|
|
344
|
+
*/
|
|
345
|
+
async function stageablePaths(git, vaultPath, files) {
|
|
346
|
+
const absent = files.filter(f => !existsSync(join(vaultPath, f)));
|
|
347
|
+
if (absent.length === 0)
|
|
348
|
+
return files;
|
|
349
|
+
// -z: NUL-separated, so git never quotes/escapes the paths it echoes back.
|
|
350
|
+
const tracked = new Set((await git.raw(['ls-files', '-z', '--', ...absent])).split('\0').filter(Boolean));
|
|
351
|
+
const absentSet = new Set(absent);
|
|
352
|
+
return files.filter(f => !absentSet.has(f) || tracked.has(f));
|
|
353
|
+
}
|
|
268
354
|
/** Get list of files with unresolved conflicts */
|
|
269
355
|
async function getConflictingFiles(git) {
|
|
270
356
|
try {
|
|
@@ -280,13 +366,35 @@ const FILE_STATUS_MAP = {
|
|
|
280
366
|
M: 'updated',
|
|
281
367
|
D: 'deleted',
|
|
282
368
|
};
|
|
369
|
+
/**
|
|
370
|
+
* One `--name-status` line → the (path, operation) pairs it stands for.
|
|
371
|
+
*
|
|
372
|
+
* Rename and copy lines carry a similarity score and TWO paths
|
|
373
|
+
* (`R100\told\tnew`), so they match neither the single-path shape nor a
|
|
374
|
+
* bare status letter. A promote moves entities/<slug>.md to
|
|
375
|
+
* knowledge/<slug>.md, which git reports exactly that way — dropping the
|
|
376
|
+
* line made every promote invisible in the changelog. A rename reads as the
|
|
377
|
+
* old path disappearing and the new one appearing; a copy leaves the source
|
|
378
|
+
* in place, so only the destination is news.
|
|
379
|
+
*/
|
|
380
|
+
function parseNameStatusLine(line) {
|
|
381
|
+
const moveMatch = line.match(/^([RC])\d*\t([^\t]+)\t(.+)$/);
|
|
382
|
+
if (moveMatch) {
|
|
383
|
+
const [, statusChar, from, to] = moveMatch;
|
|
384
|
+
const dest = [{ path: to, operation: 'created' }];
|
|
385
|
+
return statusChar === 'C' ? dest : [{ path: from, operation: 'deleted' }, ...dest];
|
|
386
|
+
}
|
|
387
|
+
const match = line.match(/^([A-Z])\t(.+)$/);
|
|
388
|
+
if (!match)
|
|
389
|
+
return [];
|
|
390
|
+
const operation = FILE_STATUS_MAP[match[1]];
|
|
391
|
+
return operation ? [{ path: match[2], operation }] : [];
|
|
392
|
+
}
|
|
283
393
|
export async function gitChangelog(vaultPath, since) {
|
|
284
394
|
const git = simpleGit(vaultPath);
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
// failed. Normalise bare dates to local midnight.
|
|
289
|
-
const normalizedSince = /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since}T00:00:00` : since;
|
|
395
|
+
// Bare dates become local midnight — the same calendar-day boundary the
|
|
396
|
+
// vault stamps and query comparisons use (see dates.ts).
|
|
397
|
+
const normalizedSince = normalizeSinceDate(since);
|
|
290
398
|
let output;
|
|
291
399
|
try {
|
|
292
400
|
output = await git.raw([
|
|
@@ -310,21 +418,16 @@ export async function gitChangelog(vaultPath, since) {
|
|
|
310
418
|
continue;
|
|
311
419
|
const [, , timestamp, commit_message] = headerMatch;
|
|
312
420
|
for (let i = 1; i < lines.length; i++) {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
continue;
|
|
324
|
-
const classified = classifyIndexedRelPath(filePath);
|
|
325
|
-
if (!classified)
|
|
326
|
-
continue;
|
|
327
|
-
entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
|
|
421
|
+
for (const { path: filePath, operation } of parseNameStatusLine(lines[i])) {
|
|
422
|
+
// Only vault documents — scaffold files (.gitkeep) live in the same
|
|
423
|
+
// dirs and would otherwise surface as changelog rows.
|
|
424
|
+
if (!filePath.endsWith('.md'))
|
|
425
|
+
continue;
|
|
426
|
+
const classified = classifyIndexedRelPath(filePath);
|
|
427
|
+
if (!classified)
|
|
428
|
+
continue;
|
|
429
|
+
entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
|
|
430
|
+
}
|
|
328
431
|
}
|
|
329
432
|
}
|
|
330
433
|
return entries;
|
|
@@ -377,10 +480,19 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
|
|
|
377
480
|
writeFileSync(fullPath, content);
|
|
378
481
|
}
|
|
379
482
|
}
|
|
380
|
-
await git
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
483
|
+
const stageable = await stageablePaths(git, vaultPath, files);
|
|
484
|
+
if (stageable.length === 0)
|
|
485
|
+
return;
|
|
486
|
+
await git.add(stageable);
|
|
487
|
+
// Handle "nothing to commit" (e.g., idempotent update wrote identical
|
|
488
|
+
// content). Ask the index directly rather than via simple-git's status:
|
|
489
|
+
// its `staged` list omits renamed paths (they go to `renamed` only), so a
|
|
490
|
+
// promote git detected as a rename read as "nothing to commit" and was
|
|
491
|
+
// silently dropped — left staged, uncommitted, absent from the changelog.
|
|
492
|
+
// Same output-based protocol the rebase loop uses, and it is well-defined
|
|
493
|
+
// against an unborn HEAD (diffs the empty tree).
|
|
494
|
+
const stagedPaths = (await git.raw(['diff', '--cached', '--name-only'])).trim();
|
|
495
|
+
if (stagedPaths.length === 0)
|
|
384
496
|
return;
|
|
385
497
|
const committed = await git.commit(message);
|
|
386
498
|
// simple-git swallows some commit failures (e.g. a rejected pre-commit
|
|
@@ -415,7 +527,12 @@ export function createGitJournal(vaultPath, config = {}) {
|
|
|
415
527
|
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
416
528
|
await gitUnstage(vaultPath, files);
|
|
417
529
|
}
|
|
418
|
-
|
|
530
|
+
// The Journal is the seam: git's own error types must not escape it,
|
|
531
|
+
// or callers (and the CLI exit-code table) see an unclassified failure.
|
|
532
|
+
if (err instanceof HafezError)
|
|
533
|
+
throw err;
|
|
534
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
535
|
+
throw new HafezError('GIT_COMMIT_FAILED', `git commit failed for: ${files.join(', ')}`, [detail]);
|
|
419
536
|
}
|
|
420
537
|
},
|
|
421
538
|
sync: () => gitSync(vaultPath),
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Hafez, HafezConfig } from './types.js';
|
|
2
2
|
export declare function createHafez(config: HafezConfig): Hafez;
|
|
3
3
|
export { HafezError } from './types.js';
|
|
4
|
-
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
5
|
-
export { getBrief, getNextActions
|
|
4
|
+
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
5
|
+
export { getBrief, getNextActions } from './document.js';
|
|
6
6
|
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|