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.
@@ -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 = fm.subtype ? `knowledge/${fm.subtype}` : 'knowledge';
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
- // Slug→dir map: insertion order entities/knowledge/sessions, first write wins,
102
- // giving entity precedence on cross-kind slug collisions.
103
- const slugDirs = new Map();
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 (!slugDirs.has(doc.slug))
106
- slugDirs.set(doc.slug, doc.dir);
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 dir = slugDirs.get(slug);
114
- if (!dir) {
140
+ const path = slugPaths.get(slug);
141
+ if (!path) {
115
142
  report.unresolvedLinks++;
116
143
  return label;
117
144
  }
118
- // Bundle-root-absolute path, hardcoded '/' separators
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.slug}.md`), serializeFile(mapped, transformBody(doc.body)));
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({ slug: doc.slug, title: mapped.title, description: mapped.description ?? '' });
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.slug}.md)${desc}`);
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.push();
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
- // Always pull first (bidirectional sync)
230
- const headBefore = (await git.revparse(['HEAD'])).trim();
231
- for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
232
- try {
233
- await pullResolving(git, vaultPath, syncConflictMessage);
234
- const headAfter = (await git.revparse(['HEAD'])).trim();
235
- pulled = headAfter !== headBefore;
236
- break;
237
- }
238
- catch (err) {
239
- if (err instanceof HafezError)
240
- throw err;
241
- if (attempt === MAX_PUSH_RETRIES) {
242
- const detail = err instanceof Error ? err.message : String(err);
243
- throw new HafezError('GIT_PUSH_FAILED', `Git pull failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
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
- // git approxidate fills a date-only --since with the *current* time of
286
- // day, so `--since <today>` means "since right now" and silently returns
287
- // nothing — the obvious "what happened today" query was the one that
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 fileMatch = lines[i].match(/^([AMDRC])\t(.+)$/);
314
- if (!fileMatch)
315
- continue;
316
- const [, statusChar, filePath] = fileMatch;
317
- const operation = FILE_STATUS_MAP[statusChar];
318
- if (!operation)
319
- continue;
320
- // Only vault documents — scaffold files (.gitkeep) live in the same
321
- // dirs and would otherwise surface as changelog rows.
322
- if (!filePath.endsWith('.md'))
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.add(files);
381
- // Handle "nothing to commit" (e.g., idempotent update wrote identical content)
382
- const status = await git.status();
383
- if (status.staged.length === 0)
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
- throw err;
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, findSection } from './document.js';
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';