hafez 1.0.1

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/dist/cli/batch-errors.d.ts +24 -0
  4. package/dist/cli/batch-errors.js +236 -0
  5. package/dist/cli/commands-schema.d.ts +3 -0
  6. package/dist/cli/commands-schema.js +80 -0
  7. package/dist/cli/commands.d.ts +133 -0
  8. package/dist/cli/commands.js +628 -0
  9. package/dist/cli/format.d.ts +9 -0
  10. package/dist/cli/format.js +158 -0
  11. package/dist/cli/help-agent.d.ts +6 -0
  12. package/dist/cli/help-agent.js +158 -0
  13. package/dist/cli/index.d.ts +1 -0
  14. package/dist/cli/index.js +304 -0
  15. package/dist/cli/resolve-vault.d.ts +13 -0
  16. package/dist/cli/resolve-vault.js +124 -0
  17. package/dist/cli/schema-introspect.d.ts +70 -0
  18. package/dist/cli/schema-introspect.js +372 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +3 -0
  21. package/dist/contracts.d.ts +26 -0
  22. package/dist/contracts.js +87 -0
  23. package/dist/db.d.ts +68 -0
  24. package/dist/db.js +663 -0
  25. package/dist/digest.d.ts +29 -0
  26. package/dist/digest.js +94 -0
  27. package/dist/export-okf.d.ts +12 -0
  28. package/dist/export-okf.js +176 -0
  29. package/dist/git.d.ts +12 -0
  30. package/dist/git.js +355 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +909 -0
  33. package/dist/knowledge-index.d.ts +3 -0
  34. package/dist/knowledge-index.js +135 -0
  35. package/dist/merge.d.ts +1 -0
  36. package/dist/merge.js +176 -0
  37. package/dist/migrate-knowledge-v2.d.ts +11 -0
  38. package/dist/migrate-knowledge-v2.js +158 -0
  39. package/dist/migrate-next-actions.d.ts +6 -0
  40. package/dist/migrate-next-actions.js +32 -0
  41. package/dist/migrate-types.d.ts +11 -0
  42. package/dist/migrate-types.js +142 -0
  43. package/dist/parser.d.ts +18 -0
  44. package/dist/parser.js +68 -0
  45. package/dist/query.d.ts +24 -0
  46. package/dist/query.js +167 -0
  47. package/dist/schema.d.ts +9 -0
  48. package/dist/schema.js +59 -0
  49. package/dist/sections.d.ts +54 -0
  50. package/dist/sections.js +297 -0
  51. package/dist/templates.d.ts +8 -0
  52. package/dist/templates.js +39 -0
  53. package/dist/types.d.ts +321 -0
  54. package/dist/types.js +11 -0
  55. package/dist/vault.d.ts +12 -0
  56. package/dist/vault.js +45 -0
  57. package/package.json +67 -0
  58. package/skill/SKILL.md +190 -0
package/dist/digest.js ADDED
@@ -0,0 +1,94 @@
1
+ // src/digest.ts
2
+ import { z } from 'zod';
3
+ // --- Schema ---
4
+ export const DigestInputSchema = z.object({
5
+ entities_touched: z.array(z.string())
6
+ .describe('Slugs of entities worked on. Each gets a session log entry; the session note links them all. Unknown slugs warn on stderr and are omitted.'),
7
+ decisions: z.array(z.string())
8
+ .describe('Key decisions made this session (may be empty). Recorded in the session note.')
9
+ .default([]),
10
+ narrative: z.string().min(1)
11
+ .describe('1-3 sentence cross-cutting summary — what happened, connections discovered.'),
12
+ session_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
13
+ .describe('Session date, YYYY-MM-DD.'),
14
+ agent: z.string()
15
+ .describe('Agent identifier recorded in session logs.')
16
+ .default('claude'),
17
+ });
18
+ /**
19
+ * Validate and parse raw digest input from stdin.
20
+ * Returns unknown fields so callers can warn on stderr.
21
+ * Uses strict() parse to detect unknown keys, then normal parse for the clean result.
22
+ */
23
+ export function parseDigestInput(raw) {
24
+ const result = DigestInputSchema.safeParse(raw);
25
+ if (!result.success) {
26
+ return { success: false, error: result.error };
27
+ }
28
+ // Detect unknown fields via strict parse (Zod's unrecognized_keys error code)
29
+ const unknownFields = [];
30
+ const strictResult = DigestInputSchema.strict().safeParse(raw);
31
+ if (!strictResult.success) {
32
+ for (const issue of strictResult.error.issues) {
33
+ if (issue.code === 'unrecognized_keys')
34
+ unknownFields.push(...issue.keys);
35
+ }
36
+ }
37
+ return { success: true, data: result.data, unknownFields };
38
+ }
39
+ /**
40
+ * Convert session context into a hafez batch payload.
41
+ * Pure function — no file I/O, no vault access.
42
+ * Unknown slugs in entities_touched are silently omitted (caller should warn).
43
+ */
44
+ export function digest(input, existingSlugs, now = new Date()) {
45
+ const ops = [];
46
+ // 1. Session log updates for each known slug
47
+ const knownTouched = input.entities_touched.filter(s => existingSlugs.has(s));
48
+ for (const slug of knownTouched) {
49
+ const sessionLogSummary = buildSessionLogSummary(input);
50
+ const fields = {
51
+ session_log: {
52
+ type: 'progress',
53
+ summary: sessionLogSummary,
54
+ agent: input.agent,
55
+ },
56
+ };
57
+ ops.push({ op: 'update', slug, fields });
58
+ }
59
+ // 2. Session note — built from known slugs only, so unknown slugs never
60
+ // leak into the note body (the schema promises they are omitted).
61
+ const noteBody = buildSessionNoteBody(input, knownTouched);
62
+ const related = knownTouched;
63
+ const noteFields = {
64
+ synthesis: noteBody,
65
+ 'session-date': input.session_date,
66
+ ...(related.length > 0 ? { related } : {}),
67
+ };
68
+ const noteName = `Session ${input.session_date} ${now.toISOString().slice(11, 19)}`;
69
+ ops.push({ op: 'create', kind: 'session', name: noteName, fields: noteFields });
70
+ return ops;
71
+ }
72
+ // --- Helpers ---
73
+ function buildSessionLogSummary(input) {
74
+ const parts = [input.narrative.trim()];
75
+ if (input.decisions.length > 0) {
76
+ parts.push(`Decisions: ${input.decisions.join('; ')}`);
77
+ }
78
+ return parts.join(' | ');
79
+ }
80
+ function buildSessionNoteBody(input, knownTouched) {
81
+ const lines = [input.narrative.trim()];
82
+ if (input.decisions.length > 0) {
83
+ lines.push('');
84
+ lines.push('**Decisions:**');
85
+ for (const d of input.decisions) {
86
+ lines.push(`- ${d}`);
87
+ }
88
+ }
89
+ if (knownTouched.length > 0) {
90
+ lines.push('');
91
+ lines.push(`**Entities touched:** ${knownTouched.join(', ')}`);
92
+ }
93
+ return lines.join('\n');
94
+ }
@@ -0,0 +1,12 @@
1
+ export interface OkfExportReport {
2
+ outDir: string;
3
+ entities: number;
4
+ knowledge: number;
5
+ sessions: number;
6
+ skipped: Array<{
7
+ file: string;
8
+ reason: string;
9
+ }>;
10
+ unresolvedLinks: number;
11
+ }
12
+ export declare function exportOkf(vaultPath: string, outDir: string): OkfExportReport;
@@ -0,0 +1,176 @@
1
+ // src/export-okf.ts
2
+ // Read-only export of the vault as an OKF v0.1 bundle (entities + knowledge + sessions).
3
+ // Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
4
+ // Pure sync function — no Hafez instance, index, git, or lock (mirrors migrate-*.ts).
5
+ // Re-export overwrites a prior bundle in place but never deletes: files for vault
6
+ // docs renamed or removed since the last export linger (unreferenced by the
7
+ // regenerated indexes) until the bundle directory is deleted and re-exported.
8
+ import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
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));
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ function mapFrontmatter(doc) {
29
+ const { fm, kind, body } = doc;
30
+ const mapped = {};
31
+ if (kind === 'entity')
32
+ mapped.type = fm.type ?? 'entity';
33
+ else if (kind === 'knowledge')
34
+ mapped.type = fm.subtype ? `knowledge/${fm.subtype}` : 'knowledge';
35
+ else
36
+ mapped.type = 'session';
37
+ mapped.title = fm.name;
38
+ const description = deriveSummary(kind, fm, body);
39
+ if (description)
40
+ mapped.description = description;
41
+ if (kind === 'entity' && fm.resource)
42
+ mapped.resource = fm.resource;
43
+ if (fm.tags)
44
+ mapped.tags = fm.tags;
45
+ let timestamp;
46
+ if (kind === 'entity')
47
+ timestamp = fm['last-touched'] ?? fm.created;
48
+ else if (kind === 'knowledge')
49
+ timestamp = fm['last-reinforced'] ?? fm.created;
50
+ else
51
+ timestamp = fm['session-date'] ?? fm.created;
52
+ if (timestamp)
53
+ mapped.timestamp = timestamp;
54
+ // Carry all remaining original keys verbatim; drop only name (mapped to title).
55
+ const consumed = new Set(['name', 'type', 'description', 'resource', 'tags']);
56
+ for (const [key, value] of Object.entries(fm)) {
57
+ if (!consumed.has(key) && !(key in mapped))
58
+ mapped[key] = value;
59
+ }
60
+ return mapped;
61
+ }
62
+ export function exportOkf(vaultPath, outDir) {
63
+ const resolvedVault = resolve(vaultPath);
64
+ const resolvedOut = resolve(outDir);
65
+ if (resolvedOut === resolvedVault || resolvedOut.startsWith(resolvedVault + sep)) {
66
+ throw new Error(`Export target ${resolvedOut} is inside the vault. Pass --out <path> outside the vault.`);
67
+ }
68
+ if (existsSync(resolvedOut)) {
69
+ const existing = readdirSync(resolvedOut);
70
+ if (existing.length > 0) {
71
+ const rootIndex = join(resolvedOut, 'index.md');
72
+ let isPriorBundle = false;
73
+ if (existsSync(rootIndex)) {
74
+ try {
75
+ isPriorBundle = parseFilePath(rootIndex).frontmatter.okf_version !== undefined;
76
+ }
77
+ catch { /* unparseable — treat as not a bundle */ }
78
+ }
79
+ if (!isPriorBundle) {
80
+ throw new Error(`Export target ${resolvedOut} is non-empty and not a prior OKF bundle. Refusing to write.`);
81
+ }
82
+ }
83
+ }
84
+ const report = {
85
+ outDir: resolvedOut, entities: 0, knowledge: 0, sessions: 0,
86
+ skipped: [], unresolvedLinks: 0,
87
+ };
88
+ // Enumerate and parse. Sessions are not in SQLite — disk enumeration is required.
89
+ const docs = [];
90
+ for (const { dir, kind } of KIND_DIRS) {
91
+ for (const filePath of scanDir(join(resolvedVault, dir))) {
92
+ const slug = basename(filePath, '.md');
93
+ const relFile = `${dir}/${slug}.md`;
94
+ let fm, body;
95
+ try {
96
+ const parsed = parseFilePath(filePath);
97
+ fm = parsed.frontmatter;
98
+ body = parsed.body;
99
+ }
100
+ catch (err) {
101
+ report.skipped.push({ file: relFile, reason: `failed to parse (${err.message})` });
102
+ process.stderr.write(`Warning: skipping ${relFile}: failed to parse\n`);
103
+ continue;
104
+ }
105
+ if (!fm.name) {
106
+ report.skipped.push({ file: relFile, reason: 'missing name' });
107
+ process.stderr.write(`Warning: skipping ${relFile}: missing name\n`);
108
+ continue;
109
+ }
110
+ docs.push({ slug, dir, kind, fm, body });
111
+ }
112
+ }
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();
116
+ for (const doc of docs) {
117
+ if (!slugDirs.has(doc.slug))
118
+ slugDirs.set(doc.slug, doc.dir);
119
+ }
120
+ function transformBody(body) {
121
+ return body.replace(OKF_LINK_RE, (_m, rawSlug, rawDisplay) => {
122
+ // Trim: Obsidian-idiomatic [[ foo ]] / [[foo | Label]] carry whitespace in the captures
123
+ const slug = rawSlug.trim();
124
+ const label = rawDisplay?.trim() || slug;
125
+ const dir = slugDirs.get(slug);
126
+ if (!dir) {
127
+ report.unresolvedLinks++;
128
+ return label;
129
+ }
130
+ // Bundle-root-absolute path, hardcoded '/' separators
131
+ return `[${label}](/${dir}/${slug}.md)`;
132
+ });
133
+ }
134
+ mkdirSync(resolvedOut, { recursive: true });
135
+ const byDir = new Map();
136
+ for (const doc of docs) {
137
+ const mapped = mapFrontmatter(doc);
138
+ const outSubdir = join(resolvedOut, doc.dir);
139
+ mkdirSync(outSubdir, { recursive: true });
140
+ writeFileSync(join(outSubdir, `${doc.slug}.md`), serializeFile(mapped, transformBody(doc.body)));
141
+ if (doc.kind === 'entity')
142
+ report.entities++;
143
+ else if (doc.kind === 'knowledge')
144
+ report.knowledge++;
145
+ else
146
+ report.sessions++;
147
+ const list = byDir.get(doc.dir) ?? [];
148
+ list.push({ slug: doc.slug, title: mapped.title, description: mapped.description ?? '' });
149
+ byDir.set(doc.dir, list);
150
+ }
151
+ // Per-dir index.md — reserved file: NO frontmatter, sorted bullets
152
+ for (const [dir, items] of byDir) {
153
+ const heading = dir.charAt(0).toUpperCase() + dir.slice(1);
154
+ const lines = [`# ${heading}`, ''];
155
+ for (const item of items.sort((a, b) => a.title.localeCompare(b.title))) {
156
+ const desc = item.description ? ` — ${item.description}` : '';
157
+ lines.push(`- [${item.title}](/${dir}/${item.slug}.md)${desc}`);
158
+ }
159
+ writeFileSync(join(resolvedOut, dir, 'index.md'), lines.join('\n') + '\n');
160
+ }
161
+ // Root index.md — the only place okf_version may appear
162
+ const counts = [
163
+ ['entities', report.entities],
164
+ ['knowledge', report.knowledge],
165
+ ['sessions', report.sessions],
166
+ ];
167
+ const rootLines = ['# Vault Export', ''];
168
+ for (const [dir, count] of counts) {
169
+ if (count === 0)
170
+ continue;
171
+ const heading = dir.charAt(0).toUpperCase() + dir.slice(1);
172
+ rootLines.push(`- [${heading}](/${dir}/index.md) — ${count} concepts`);
173
+ }
174
+ writeFileSync(join(resolvedOut, 'index.md'), serializeFile({ okf_version: '0.1' }, rootLines.join('\n')));
175
+ return report;
176
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { type GitConfig, type ChangelogEntry } from './types.js';
2
+ /**
3
+ * Bidirectional sync: pull remote changes, push local commits.
4
+ * All work must be committed before calling — no file save/restore needed.
5
+ */
6
+ export declare function gitSync(vaultPath: string): Promise<{
7
+ pulled: boolean;
8
+ pushed: boolean;
9
+ remote: boolean;
10
+ }>;
11
+ export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
12
+ export declare function gitCommitAndPush(vaultPath: string, files: string[], message: string, config?: GitConfig): Promise<void>;
package/dist/git.js ADDED
@@ -0,0 +1,355 @@
1
+ // src/git.ts
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { simpleGit } from 'simple-git';
5
+ import { mergeVaultContent } from './merge.js';
6
+ import { HafezError } from './types.js';
7
+ const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
8
+ const MAX_PUSH_RETRIES = 3;
9
+ const RETRY_BASE_MS = 200;
10
+ function isRebaseInProgress(vaultPath) {
11
+ return (existsSync(join(vaultPath, '.git', 'rebase-merge')) ||
12
+ existsSync(join(vaultPath, '.git', 'rebase-apply')));
13
+ }
14
+ function isMergeInProgress(vaultPath) {
15
+ return existsSync(join(vaultPath, '.git', 'MERGE_HEAD'));
16
+ }
17
+ /**
18
+ * Abort any in-progress rebase or merge, restoring the repo to a clean state.
19
+ * Silently ignores "nothing to abort" errors (TOCTOU race: another process
20
+ * may have resolved the state between our check and the abort call).
21
+ */
22
+ async function ensureCleanState(git, vaultPath) {
23
+ if (isRebaseInProgress(vaultPath)) {
24
+ try {
25
+ await git.rebase(['--abort']);
26
+ }
27
+ catch {
28
+ // Already resolved by another process — safe to ignore
29
+ }
30
+ }
31
+ if (isMergeInProgress(vaultPath)) {
32
+ try {
33
+ await git.raw(['merge', '--abort']);
34
+ }
35
+ catch {
36
+ // Already resolved by another process — safe to ignore
37
+ }
38
+ }
39
+ }
40
+ const VAULT_FILE_RE = /^(?:entities|knowledge|sessions)\/.+\.md$/;
41
+ /**
42
+ * True if the vault repo has an `origin` remote. A local-only vault (the
43
+ * default the README setup produces) has none — every pull/push must be
44
+ * skipped for it, silently: the commit IS the whole job.
45
+ */
46
+ async function hasOriginRemote(git) {
47
+ const remotes = await git.getRemotes();
48
+ return remotes.some(r => r.name === 'origin');
49
+ }
50
+ async function attemptSemanticMerge(git, vaultPath, files, message) {
51
+ // Only merge vault files — non-vault files fall through to error
52
+ if (!files.every(f => VAULT_FILE_RE.test(f)))
53
+ return false;
54
+ logMerge(`conflict detected, attempting semantic merge for: ${files.join(', ')}`);
55
+ const branch = (await git.branchLocal()).current;
56
+ // Save local commit SHA so we can restore if merge fails after reset
57
+ const localSHA = (await git.log(['-1'])).latest.hash;
58
+ // Read local content from working tree (preserved after rebase abort)
59
+ const localContents = new Map();
60
+ for (const file of files) {
61
+ localContents.set(file, readFileSync(join(vaultPath, file), 'utf-8'));
62
+ }
63
+ // Get remote content and merge — all done BEFORE any destructive ops
64
+ const mergedContents = new Map();
65
+ try {
66
+ for (const file of files) {
67
+ let remoteContent;
68
+ try {
69
+ remoteContent = await git.show(`origin/${branch}:${file}`);
70
+ }
71
+ catch {
72
+ // File doesn't exist on remote — no merge needed, keep local
73
+ mergedContents.set(file, localContents.get(file));
74
+ continue;
75
+ }
76
+ mergedContents.set(file, mergeVaultContent(remoteContent, localContents.get(file)));
77
+ }
78
+ }
79
+ catch {
80
+ // Merge failed (malformed content, etc.) — local commit is still intact
81
+ return false;
82
+ }
83
+ // --- Destructive section: reset + rewrite ---
84
+ // If anything fails below, restore the local commit
85
+ try {
86
+ await git.reset(['--hard', `origin/${branch}`]);
87
+ for (const [file, content] of mergedContents) {
88
+ const fullPath = join(vaultPath, file);
89
+ mkdirSync(dirname(fullPath), { recursive: true });
90
+ writeFileSync(fullPath, content);
91
+ }
92
+ await git.add(files);
93
+ const status = await git.status();
94
+ if (status.staged.length === 0) {
95
+ // Contents identical after merge — nothing to commit, but we're on remote now
96
+ return true;
97
+ }
98
+ await git.commit(message);
99
+ logMerge(`semantic merge succeeded for: ${files.join(', ')}`);
100
+ return true;
101
+ }
102
+ catch (err) {
103
+ // Restore local commit — "changes saved locally" must remain true
104
+ logMerge(`merge failed after reset, restoring local commit ${localSHA}: ${err}`);
105
+ try {
106
+ await git.reset(['--hard', localSHA]);
107
+ }
108
+ catch { /* last resort failed */ }
109
+ return false;
110
+ }
111
+ }
112
+ /**
113
+ * Bidirectional sync: pull remote changes, push local commits.
114
+ * All work must be committed before calling — no file save/restore needed.
115
+ */
116
+ export async function gitSync(vaultPath) {
117
+ const git = simpleGit(vaultPath);
118
+ if (!(await hasOriginRemote(git))) {
119
+ return { pulled: false, pushed: false, remote: false };
120
+ }
121
+ // Clean up any stuck rebase/merge from a previous crash.
122
+ // Safe to abort without saving files — all work is already committed.
123
+ await ensureCleanState(git, vaultPath);
124
+ let pulled = false;
125
+ // Always pull first (bidirectional sync)
126
+ const headBefore = (await git.revparse(['HEAD'])).trim();
127
+ for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
128
+ try {
129
+ await git.pull('origin', undefined, { '--rebase': null });
130
+ const headAfter = (await git.revparse(['HEAD'])).trim();
131
+ pulled = headAfter !== headBefore;
132
+ break;
133
+ }
134
+ catch (err) {
135
+ const hadConflict = isRebaseInProgress(vaultPath) || isMergeInProgress(vaultPath);
136
+ if (hadConflict) {
137
+ // Discover conflicting files from git status
138
+ const conflictFiles = await getConflictingFiles(git);
139
+ try {
140
+ await ensureCleanState(git, vaultPath);
141
+ }
142
+ catch { /* preserve error */ }
143
+ if (conflictFiles.length > 0) {
144
+ const merged = await attemptSemanticMerge(git, vaultPath, conflictFiles, `sync: resolve conflicts`);
145
+ if (merged) {
146
+ pulled = true;
147
+ break;
148
+ }
149
+ }
150
+ // Merge not possible
151
+ const detail = err instanceof Error ? err.message : String(err);
152
+ throw new HafezError('GIT_PUSH_FAILED', `Git conflict during sync (files: ${conflictFiles.join(', ')}). Changes saved locally.`, [detail]);
153
+ }
154
+ try {
155
+ await ensureCleanState(git, vaultPath);
156
+ }
157
+ catch { /* preserve error */ }
158
+ if (attempt === MAX_PUSH_RETRIES) {
159
+ const detail = err instanceof Error ? err.message : String(err);
160
+ throw new HafezError('GIT_PUSH_FAILED', `Git pull failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
161
+ }
162
+ await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
163
+ }
164
+ }
165
+ // Check if there are local commits to push
166
+ const ahead = await git.raw(['rev-list', '--count', '@{u}..HEAD']).then(s => parseInt(s.trim(), 10));
167
+ if (ahead === 0)
168
+ return { pulled, pushed: false, remote: true };
169
+ // Push local commits
170
+ for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
171
+ try {
172
+ await git.push();
173
+ return { pulled, pushed: true, remote: true };
174
+ }
175
+ catch (err) {
176
+ // Another writer may have pushed — pull and retry
177
+ try {
178
+ await git.pull('origin', undefined, { '--rebase': null });
179
+ }
180
+ catch (pullErr) {
181
+ const hadConflict = isRebaseInProgress(vaultPath) || isMergeInProgress(vaultPath);
182
+ if (hadConflict) {
183
+ const conflictFiles = await getConflictingFiles(git);
184
+ try {
185
+ await ensureCleanState(git, vaultPath);
186
+ }
187
+ catch { /* preserve error */ }
188
+ if (conflictFiles.length > 0) {
189
+ const merged = await attemptSemanticMerge(git, vaultPath, conflictFiles, `sync: resolve conflicts`);
190
+ if (merged)
191
+ continue; // retry push
192
+ }
193
+ }
194
+ else {
195
+ try {
196
+ await ensureCleanState(git, vaultPath);
197
+ }
198
+ catch { /* preserve error */ }
199
+ }
200
+ }
201
+ if (attempt === MAX_PUSH_RETRIES) {
202
+ const detail = err instanceof Error ? err.message : String(err);
203
+ throw new HafezError('GIT_PUSH_FAILED', `Git push failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
204
+ }
205
+ await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
206
+ }
207
+ }
208
+ return { pulled, pushed: false, remote: true }; // unreachable, but satisfies TS
209
+ }
210
+ /** Get list of files with unresolved conflicts */
211
+ async function getConflictingFiles(git) {
212
+ try {
213
+ const output = await git.diff(['--name-only', '--diff-filter=U']);
214
+ return output.split('\n').map(f => f.trim()).filter(Boolean);
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ const FILE_STATUS_MAP = {
221
+ A: 'created',
222
+ M: 'updated',
223
+ D: 'deleted',
224
+ };
225
+ export async function gitChangelog(vaultPath, since) {
226
+ const git = simpleGit(vaultPath);
227
+ let output;
228
+ try {
229
+ output = await git.raw([
230
+ 'log', `--since=${since}`, '--name-status',
231
+ '--pretty=format:%H|%aI|%s', '--', 'entities/', 'knowledge/',
232
+ ]);
233
+ }
234
+ catch {
235
+ return []; // No git history or invalid since — return empty
236
+ }
237
+ if (!output.trim())
238
+ return [];
239
+ const entries = [];
240
+ const blocks = output.split(/\n(?=[0-9a-f]{40}\|)/);
241
+ for (const block of blocks) {
242
+ const lines = block.split('\n').filter(l => l.trim());
243
+ if (lines.length === 0)
244
+ continue;
245
+ const headerMatch = lines[0].match(/^([0-9a-f]+)\|(.+?)\|(.*)$/);
246
+ if (!headerMatch)
247
+ continue;
248
+ const [, , timestamp, commit_message] = headerMatch;
249
+ for (let i = 1; i < lines.length; i++) {
250
+ const fileMatch = lines[i].match(/^([AMDRC])\t(.+)$/);
251
+ if (!fileMatch)
252
+ continue;
253
+ const [, statusChar, filePath] = fileMatch;
254
+ const operation = FILE_STATUS_MAP[statusChar];
255
+ if (!operation)
256
+ continue;
257
+ let kind;
258
+ let slug;
259
+ if (filePath.startsWith('entities/')) {
260
+ kind = 'entity';
261
+ slug = filePath.replace('entities/', '').replace('.md', '');
262
+ }
263
+ else if (filePath.startsWith('knowledge/')) {
264
+ kind = 'knowledge';
265
+ slug = filePath.replace('knowledge/', '').replace('.md', '');
266
+ }
267
+ else {
268
+ continue;
269
+ }
270
+ entries.push({ slug, kind, operation, timestamp, commit_message });
271
+ }
272
+ }
273
+ return entries;
274
+ }
275
+ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
276
+ const git = simpleGit(vaultPath);
277
+ // If a previous run crashed mid-rebase/merge, the repo is stuck.
278
+ // Aborting restores tracked files to their pre-rebase state, which may
279
+ // overwrite files that index.ts just wrote. Save and restore them.
280
+ if (isRebaseInProgress(vaultPath) || isMergeInProgress(vaultPath)) {
281
+ const saved = new Map();
282
+ for (const f of files) {
283
+ const full = join(vaultPath, f);
284
+ if (existsSync(full))
285
+ saved.set(full, readFileSync(full));
286
+ }
287
+ await ensureCleanState(git, vaultPath);
288
+ for (const [fullPath, content] of saved) {
289
+ writeFileSync(fullPath, content);
290
+ }
291
+ }
292
+ await git.add(files);
293
+ // Handle "nothing to commit" (e.g., idempotent update wrote identical content)
294
+ const status = await git.status();
295
+ if (status.staged.length === 0)
296
+ return;
297
+ await git.commit(message);
298
+ if (config.push === false)
299
+ return;
300
+ if (!(await hasOriginRemote(git)))
301
+ return;
302
+ for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
303
+ try {
304
+ await git.pull('origin', undefined, { '--rebase': null });
305
+ await git.push();
306
+ return; // success
307
+ }
308
+ catch (err) {
309
+ const hadConflict = isRebaseInProgress(vaultPath) || isMergeInProgress(vaultPath);
310
+ // Always restore clean state before throwing or retrying.
311
+ // Wrap in try-catch to preserve the original error if abort fails.
312
+ try {
313
+ await ensureCleanState(git, vaultPath);
314
+ }
315
+ catch {
316
+ // Abort failed — repo may be in a bad state, but we still throw
317
+ // the original error so the caller gets useful diagnostics.
318
+ }
319
+ const detail = err instanceof Error ? err.message : String(err);
320
+ if (hadConflict) {
321
+ const merged = await attemptSemanticMerge(git, vaultPath, files, message);
322
+ if (merged) {
323
+ // Try pushing the merged result
324
+ try {
325
+ await git.push();
326
+ return; // success
327
+ }
328
+ catch (pushErr) {
329
+ // One final retry: another writer may have pushed during our merge
330
+ try {
331
+ await git.pull('origin', undefined, { '--rebase': null });
332
+ await git.push();
333
+ return;
334
+ }
335
+ catch (retryErr) {
336
+ try {
337
+ await ensureCleanState(git, vaultPath);
338
+ }
339
+ catch { /* preserve original error */ }
340
+ const retryDetail = retryErr instanceof Error ? retryErr.message : String(retryErr);
341
+ throw new HafezError('GIT_PUSH_FAILED', `Git push failed after semantic merge. Changes saved locally.`, [retryDetail]);
342
+ }
343
+ }
344
+ }
345
+ // Merge not possible (non-vault files) — original behavior
346
+ throw new HafezError('GIT_PUSH_FAILED', `Git conflict with remote (files: ${files.join(', ')}). Changes saved locally.`, [detail]);
347
+ }
348
+ // Transient error (network, push race). Retry if attempts remain.
349
+ if (attempt === MAX_PUSH_RETRIES) {
350
+ throw new HafezError('GIT_PUSH_FAILED', `Git push failed after ${MAX_PUSH_RETRIES} attempts. Changes saved locally.`, [detail]);
351
+ }
352
+ await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
353
+ }
354
+ }
355
+ }
@@ -0,0 +1,7 @@
1
+ import type { Hafez, HafezConfig } from './types.js';
2
+ export declare function createHafez(config: HafezConfig): Hafez;
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';