hafez 1.2.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.
Files changed (52) hide show
  1. package/README.md +67 -9
  2. package/dist/apply.d.ts +30 -0
  3. package/dist/apply.js +582 -0
  4. package/dist/batch-ops.d.ts +470 -0
  5. package/dist/batch-ops.js +446 -0
  6. package/dist/cli/args.d.ts +19 -0
  7. package/dist/cli/args.js +53 -0
  8. package/dist/cli/batch-errors.js +6 -3
  9. package/dist/cli/commands.d.ts +24 -108
  10. package/dist/cli/commands.js +297 -313
  11. package/dist/cli/help-agent.d.ts +0 -5
  12. package/dist/cli/help-agent.js +44 -5
  13. package/dist/cli/index.d.ts +41 -0
  14. package/dist/cli/index.js +172 -172
  15. package/dist/cli/resolve-vault.js +2 -1
  16. package/dist/cli/schema-introspect.d.ts +1 -1
  17. package/dist/cli/schema-introspect.js +6 -22
  18. package/dist/cli/upgrade.d.ts +42 -0
  19. package/dist/cli/upgrade.js +305 -0
  20. package/dist/cli.js +0 -0
  21. package/dist/contracts.d.ts +55 -6
  22. package/dist/contracts.js +110 -23
  23. package/dist/dates.d.ts +10 -0
  24. package/dist/dates.js +27 -0
  25. package/dist/db.d.ts +27 -2
  26. package/dist/db.js +318 -56
  27. package/dist/digest.js +2 -0
  28. package/dist/document.d.ts +117 -0
  29. package/dist/document.js +578 -0
  30. package/dist/export-okf.js +48 -34
  31. package/dist/git.d.ts +7 -8
  32. package/dist/git.js +198 -63
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +302 -915
  35. package/dist/knowledge-index.d.ts +23 -1
  36. package/dist/knowledge-index.js +71 -13
  37. package/dist/merge.d.ts +1 -1
  38. package/dist/merge.js +106 -86
  39. package/dist/schema.js +4 -4
  40. package/dist/templates.d.ts +6 -0
  41. package/dist/templates.js +27 -27
  42. package/dist/types.d.ts +86 -12
  43. package/dist/vault.d.ts +51 -6
  44. package/dist/vault.js +114 -11
  45. package/package.json +2 -2
  46. package/skill/SKILL.md +51 -110
  47. package/dist/parser.d.ts +0 -18
  48. package/dist/parser.js +0 -68
  49. package/dist/query.d.ts +0 -24
  50. package/dist/query.js +0 -167
  51. package/dist/sections.d.ts +0 -54
  52. package/dist/sections.js +0 -297
@@ -7,22 +7,36 @@
7
7
  // regenerated indexes) until the bundle directory is deleted and re-exported.
8
8
  import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
9
  import { join, resolve, sep, basename } from 'path';
10
- import { parseFilePath, serializeFile } from './vault.js';
11
- import { deriveSummary } from './sections.js';
12
- const KIND_DIRS = [
13
- { dir: 'entities', kind: 'entity' },
14
- { dir: 'knowledge', kind: 'knowledge' },
15
- { dir: 'sessions', kind: 'session' },
16
- ];
17
- // Local capturing variant of the shared WIKI_LINK_RE — captures display text in
18
- // group 2, which the shared regex's consumers don't need.
19
- const OKF_LINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
20
- function scanDir(dirPath) {
21
- try {
22
- return readdirSync(dirPath).filter(f => f.endsWith('.md')).map(f => join(dirPath, f));
10
+ import { parseFilePath, serializeFile, scanVaultDir, kindDir } from './vault.js';
11
+ import { DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
12
+ import { deriveSummary, WIKI_LINK_RE } from './document.js';
13
+ const SOURCE_KINDS = ['entity', 'knowledge', 'session'];
14
+ const KIND_DIRS = SOURCE_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
15
+ // The shared wiki-link syntax (document.ts) — group 1 slug, group 2 display.
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);
23
31
  }
24
- catch {
25
- return [];
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;
26
40
  }
27
41
  }
28
42
  function mapFrontmatter(doc) {
@@ -31,7 +45,7 @@ function mapFrontmatter(doc) {
31
45
  if (kind === 'entity')
32
46
  mapped.type = fm.type ?? 'entity';
33
47
  else if (kind === 'knowledge')
34
- mapped.type = fm.subtype ? `knowledge/${fm.subtype}` : 'knowledge';
48
+ mapped.type = `knowledge/${fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}`;
35
49
  else
36
50
  mapped.type = 'session';
37
51
  mapped.title = fm.name;
@@ -88,7 +102,7 @@ export function exportOkf(vaultPath, outDir) {
88
102
  // Enumerate and parse. Sessions are not in SQLite — disk enumeration is required.
89
103
  const docs = [];
90
104
  for (const { dir, kind } of KIND_DIRS) {
91
- for (const filePath of scanDir(join(resolvedVault, dir))) {
105
+ for (const filePath of scanVaultDir(join(resolvedVault, dir))) {
92
106
  const slug = basename(filePath, '.md');
93
107
  const relFile = `${dir}/${slug}.md`;
94
108
  let fm, body;
@@ -107,28 +121,28 @@ export function exportOkf(vaultPath, outDir) {
107
121
  process.stderr.write(`Warning: skipping ${relFile}: missing name\n`);
108
122
  continue;
109
123
  }
110
- docs.push({ slug, dir, kind, fm, body });
124
+ docs.push({ slug, dir, kind, fm, body, file: slug });
111
125
  }
112
126
  }
113
- // Slug→dir map: insertion order entities/knowledge/sessions, first write wins,
114
- // giving entity precedence on cross-kind slug collisions.
115
- 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();
116
132
  for (const doc of docs) {
117
- if (!slugDirs.has(doc.slug))
118
- slugDirs.set(doc.slug, doc.dir);
133
+ if (!slugPaths.has(doc.slug))
134
+ slugPaths.set(doc.slug, `/${doc.dir}/${doc.file}.md`);
119
135
  }
120
136
  function transformBody(body) {
121
137
  return body.replace(OKF_LINK_RE, (_m, rawSlug, rawDisplay) => {
122
- // Trim: Obsidian-idiomatic [[ foo ]] / [[foo | Label]] carry whitespace in the captures
123
138
  const slug = rawSlug.trim();
124
139
  const label = rawDisplay?.trim() || slug;
125
- const dir = slugDirs.get(slug);
126
- if (!dir) {
140
+ const path = slugPaths.get(slug);
141
+ if (!path) {
127
142
  report.unresolvedLinks++;
128
143
  return label;
129
144
  }
130
- // Bundle-root-absolute path, hardcoded '/' separators
131
- return `[${label}](/${dir}/${slug}.md)`;
145
+ return `[${label}](${path})`;
132
146
  });
133
147
  }
134
148
  mkdirSync(resolvedOut, { recursive: true });
@@ -137,7 +151,7 @@ export function exportOkf(vaultPath, outDir) {
137
151
  const mapped = mapFrontmatter(doc);
138
152
  const outSubdir = join(resolvedOut, doc.dir);
139
153
  mkdirSync(outSubdir, { recursive: true });
140
- writeFileSync(join(outSubdir, `${doc.slug}.md`), serializeFile(mapped, transformBody(doc.body)));
154
+ writeFileSync(join(outSubdir, `${doc.file}.md`), serializeFile(mapped, transformBody(doc.body)));
141
155
  if (doc.kind === 'entity')
142
156
  report.entities++;
143
157
  else if (doc.kind === 'knowledge')
@@ -145,7 +159,7 @@ export function exportOkf(vaultPath, outDir) {
145
159
  else
146
160
  report.sessions++;
147
161
  const list = byDir.get(doc.dir) ?? [];
148
- list.push({ slug: doc.slug, title: mapped.title, description: mapped.description ?? '' });
162
+ list.push({ file: doc.file, title: mapped.title, description: mapped.description ?? '' });
149
163
  byDir.set(doc.dir, list);
150
164
  }
151
165
  // Per-dir index.md — reserved file: NO frontmatter, sorted bullets
@@ -154,15 +168,15 @@ export function exportOkf(vaultPath, outDir) {
154
168
  const lines = [`# ${heading}`, ''];
155
169
  for (const item of items.sort((a, b) => a.title.localeCompare(b.title))) {
156
170
  const desc = item.description ? ` — ${item.description}` : '';
157
- lines.push(`- [${item.title}](/${dir}/${item.slug}.md)${desc}`);
171
+ lines.push(`- [${item.title}](/${dir}/${item.file}.md)${desc}`);
158
172
  }
159
173
  writeFileSync(join(resolvedOut, dir, 'index.md'), lines.join('\n') + '\n');
160
174
  }
161
175
  // Root index.md — the only place okf_version may appear
162
176
  const counts = [
163
- ['entities', report.entities],
164
- ['knowledge', report.knowledge],
165
- ['sessions', report.sessions],
177
+ [kindDir('entity'), report.entities],
178
+ [kindDir('knowledge'), report.knowledge],
179
+ [kindDir('session'), report.sessions],
166
180
  ];
167
181
  const rootLines = ['# Vault Export', ''];
168
182
  for (const [dir, count] of counts) {
package/dist/git.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type GitConfig, type ChangelogEntry } from './types.js';
1
+ import { type GitConfig, type ChangelogEntry, type Journal } from './types.js';
2
2
  /**
3
3
  * Bidirectional sync: pull remote changes, push local commits.
4
4
  * All work must be committed before calling — no file save/restore needed.
@@ -8,13 +8,6 @@ export declare function gitSync(vaultPath: string): Promise<{
8
8
  pushed: boolean;
9
9
  remote: boolean;
10
10
  }>;
11
- /**
12
- * Unstage files (best effort). Used by batch rollback: a commit that fails
13
- * after `git add` leaves the new blobs staged in .git/index, and the next
14
- * successful commit would publish the rolled-back content under the wrong
15
- * message. Clearing the index entries restores worktree/HEAD/index agreement.
16
- */
17
- export declare function gitUnstage(vaultPath: string, files: string[]): Promise<void>;
18
11
  export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
19
12
  /**
20
13
  * Latest git time per file (repo-relative, forward-slash paths). Used to
@@ -24,3 +17,9 @@ export declare function gitChangelog(vaultPath: string, since: string): Promise<
24
17
  */
25
18
  export declare function gitFileTimes(vaultPath: string, mode: 'modified' | 'added'): Promise<Map<string, string>>;
26
19
  export declare function gitCommitAndPush(vaultPath: string, files: string[], message: string, config?: GitConfig): Promise<void>;
20
+ /**
21
+ * The production Journal (see CONTEXT.md): vault persistence backed by git.
22
+ * Local-only vaults (no origin remote) commit silently without pushing —
23
+ * that behavior lives in gitCommitAndPush/gitSync, inside this adapter.
24
+ */
25
+ export declare function createGitJournal(vaultPath: string, config?: GitConfig): Journal;
package/dist/git.js CHANGED
@@ -3,6 +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, vaultKindFromRelPath } from './vault.js';
7
+ import { normalizeSinceDate } from './dates.js';
6
8
  import { HafezError } from './types.js';
7
9
  const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
8
10
  const MAX_PUSH_RETRIES = 3;
@@ -40,7 +42,6 @@ async function ensureCleanState(git, vaultPath) {
40
42
  }
41
43
  }
42
44
  }
43
- const VAULT_FILE_RE = /^(?:entities|knowledge|sessions)\/.+\.md$/;
44
45
  /**
45
46
  * True if the vault repo has an `origin` remote. A local-only vault (the
46
47
  * default the README setup produces) has none — every pull/push must be
@@ -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,44 +288,69 @@ 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
  }
255
324
  /**
256
- * Unstage files (best effort). Used by batch rollback: a commit that fails
257
- * after `git add` leaves the new blobs staged in .git/index, and the next
258
- * successful commit would publish the rolled-back content under the wrong
259
- * message. Clearing the index entries restores worktree/HEAD/index agreement.
325
+ * Unstage files (best effort). Used by the journal adapter's commit-failure
326
+ * repair: a commit that fails after `git add` leaves the new blobs staged in
327
+ * .git/index, and the next successful commit would publish rolled-back
328
+ * content under the wrong message. Clearing the index entries restores
329
+ * worktree/HEAD/index agreement.
260
330
  */
261
- export async function gitUnstage(vaultPath, files) {
331
+ async function gitUnstage(vaultPath, files) {
262
332
  try {
263
333
  await simpleGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
264
334
  }
265
335
  catch { /* best effort — rollback must not mask the original error */ }
266
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
+ }
267
354
  /** Get list of files with unresolved conflicts */
268
355
  async function getConflictingFiles(git) {
269
356
  try {
@@ -279,18 +366,40 @@ const FILE_STATUS_MAP = {
279
366
  M: 'updated',
280
367
  D: 'deleted',
281
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
+ }
282
393
  export async function gitChangelog(vaultPath, since) {
283
394
  const git = simpleGit(vaultPath);
284
- // git approxidate fills a date-only --since with the *current* time of
285
- // day, so `--since <today>` means "since right now" and silently returns
286
- // nothing — the obvious "what happened today" query was the one that
287
- // failed. Normalise bare dates to local midnight.
288
- 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);
289
398
  let output;
290
399
  try {
291
400
  output = await git.raw([
292
401
  'log', `--since=${normalizedSince}`, '--name-status',
293
- '--pretty=format:%H|%aI|%s', '--', 'entities/', 'knowledge/',
402
+ '--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
294
403
  ]);
295
404
  }
296
405
  catch {
@@ -309,31 +418,16 @@ export async function gitChangelog(vaultPath, since) {
309
418
  continue;
310
419
  const [, , timestamp, commit_message] = headerMatch;
311
420
  for (let i = 1; i < lines.length; i++) {
312
- const fileMatch = lines[i].match(/^([AMDRC])\t(.+)$/);
313
- if (!fileMatch)
314
- continue;
315
- const [, statusChar, filePath] = fileMatch;
316
- const operation = FILE_STATUS_MAP[statusChar];
317
- if (!operation)
318
- continue;
319
- // Only vault documents — scaffold files (.gitkeep) live in the same
320
- // dirs and would otherwise surface as changelog rows.
321
- if (!filePath.endsWith('.md'))
322
- continue;
323
- let kind;
324
- let slug;
325
- if (filePath.startsWith('entities/')) {
326
- kind = 'entity';
327
- slug = filePath.replace('entities/', '').replace('.md', '');
328
- }
329
- else if (filePath.startsWith('knowledge/')) {
330
- kind = 'knowledge';
331
- slug = filePath.replace('knowledge/', '').replace('.md', '');
332
- }
333
- else {
334
- continue;
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 });
335
430
  }
336
- entries.push({ slug, kind, operation, timestamp, commit_message });
337
431
  }
338
432
  }
339
433
  return entries;
@@ -348,7 +442,7 @@ export async function gitFileTimes(vaultPath, mode) {
348
442
  const git = simpleGit(vaultPath);
349
443
  let output;
350
444
  try {
351
- const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', 'entities/', 'knowledge/'];
445
+ const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', ...INDEXED_DIR_PATHSPECS];
352
446
  if (mode === 'added')
353
447
  args.splice(1, 0, '--diff-filter=A');
354
448
  output = await git.raw(args);
@@ -386,10 +480,19 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
386
480
  writeFileSync(fullPath, content);
387
481
  }
388
482
  }
389
- await git.add(files);
390
- // Handle "nothing to commit" (e.g., idempotent update wrote identical content)
391
- const status = await git.status();
392
- 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)
393
496
  return;
394
497
  const committed = await git.commit(message);
395
498
  // simple-git swallows some commit failures (e.g. a rejected pre-commit
@@ -405,3 +508,35 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
405
508
  return;
406
509
  await pushResolving(git, vaultPath, (conflictFiles) => `Git conflict with remote (files: ${conflictFiles.join(', ')}). Changes saved locally.`, `Git push failed after ${MAX_PUSH_RETRIES} attempts. Changes saved locally.`);
407
510
  }
511
+ /**
512
+ * The production Journal (see CONTEXT.md): vault persistence backed by git.
513
+ * Local-only vaults (no origin remote) commit silently without pushing —
514
+ * that behavior lives in gitCommitAndPush/gitSync, inside this adapter.
515
+ */
516
+ export function createGitJournal(vaultPath, config = {}) {
517
+ return {
518
+ async commit(written, deleted, message) {
519
+ const files = [...written, ...deleted];
520
+ try {
521
+ await gitCommitAndPush(vaultPath, files, message, config);
522
+ }
523
+ catch (err) {
524
+ // Commit-stage failure: nothing landed, but `git add` may have staged
525
+ // the new blobs — clean up our own partial state before rethrowing.
526
+ // Push-stage failure keeps the local commit (GIT_PUSH_FAILED).
527
+ if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
528
+ await gitUnstage(vaultPath, files);
529
+ }
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]);
536
+ }
537
+ },
538
+ sync: () => gitSync(vaultPath),
539
+ changelog: (since) => gitChangelog(vaultPath, since),
540
+ fileTimes: (mode) => gitFileTimes(vaultPath, mode),
541
+ };
542
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +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, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
5
- export { getBrief, getNextActions, findSection } from './sections.js';
6
- export { parseSessionLogHeading, parseSessionLog } from './parser.js';
7
- export { formatStats, formatChangelog, formatValidation } from './cli/format.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
+ export { parseSessionLogHeading, parseSessionLog } from './document.js';