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.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +470 -0
- package/dist/batch-ops.js +446 -0
- package/dist/cli/args.d.ts +19 -0
- package/dist/cli/args.js +53 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +24 -108
- package/dist/cli/commands.js +297 -313
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +44 -5
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +172 -172
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/cli.js +0 -0
- package/dist/contracts.d.ts +55 -6
- package/dist/contracts.js +110 -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 +318 -56
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +117 -0
- package/dist/document.js +578 -0
- package/dist/export-okf.js +48 -34
- package/dist/git.d.ts +7 -8
- package/dist/git.js +198 -63
- package/dist/index.d.ts +3 -4
- package/dist/index.js +302 -915
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +71 -13
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +106 -86
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +86 -12
- package/dist/vault.d.ts +51 -6
- package/dist/vault.js +114 -11
- package/package.json +2 -2
- package/skill/SKILL.md +51 -110
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -167
- package/dist/sections.d.ts +0 -54
- package/dist/sections.js +0 -297
package/dist/export-okf.js
CHANGED
|
@@ -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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
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 (!
|
|
118
|
-
|
|
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
|
|
126
|
-
if (!
|
|
140
|
+
const path = slugPaths.get(slug);
|
|
141
|
+
if (!path) {
|
|
127
142
|
report.unresolvedLinks++;
|
|
128
143
|
return label;
|
|
129
144
|
}
|
|
130
|
-
|
|
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.
|
|
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({
|
|
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.
|
|
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
|
-
['
|
|
164
|
-
['knowledge', report.knowledge],
|
|
165
|
-
['
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|
|
255
324
|
/**
|
|
256
|
-
* Unstage files (best effort). Used by
|
|
257
|
-
* after `git add` leaves the new blobs staged in
|
|
258
|
-
* successful commit would publish
|
|
259
|
-
* message. Clearing the index entries restores
|
|
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
|
-
|
|
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
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
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', '--',
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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', '--',
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
6
|
-
export { parseSessionLogHeading, parseSessionLog } from './
|
|
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';
|