hafez 1.0.1 → 1.0.2
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 +8 -10
- package/dist/cli/commands.d.ts +4 -8
- package/dist/cli/commands.js +12 -65
- package/dist/cli/index.js +4 -66
- package/dist/db.js +48 -32
- package/dist/export-okf.js +1 -1
- package/dist/git.d.ts +7 -0
- package/dist/git.js +175 -163
- package/dist/index.js +77 -16
- package/dist/merge.js +36 -9
- package/dist/types.d.ts +1 -3
- package/dist/vault.js +12 -1
- package/package.json +5 -2
- package/dist/migrate-knowledge-v2.d.ts +0 -11
- package/dist/migrate-knowledge-v2.js +0 -158
- package/dist/migrate-next-actions.d.ts +0 -6
- package/dist/migrate-next-actions.js +0 -32
- package/dist/migrate-types.d.ts +0 -11
- package/dist/migrate-types.js +0 -142
package/dist/git.js
CHANGED
|
@@ -7,6 +7,9 @@ import { HafezError } from './types.js';
|
|
|
7
7
|
const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
|
|
8
8
|
const MAX_PUSH_RETRIES = 3;
|
|
9
9
|
const RETRY_BASE_MS = 200;
|
|
10
|
+
// A pull --rebase replays one local commit at a time; each can stop on a
|
|
11
|
+
// conflict. Bound the resolution loop far above any realistic commit count.
|
|
12
|
+
const MAX_REBASE_STEPS = 50;
|
|
10
13
|
function isRebaseInProgress(vaultPath) {
|
|
11
14
|
return (existsSync(join(vaultPath, '.git', 'rebase-merge')) ||
|
|
12
15
|
existsSync(join(vaultPath, '.git', 'rebase-apply')));
|
|
@@ -47,66 +50,166 @@ async function hasOriginRemote(git) {
|
|
|
47
50
|
const remotes = await git.getRemotes();
|
|
48
51
|
return remotes.some(r => r.name === 'origin');
|
|
49
52
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
/** Read one side of a conflicted file from the index; null if that side deleted it. */
|
|
54
|
+
async function showStage(git, stage, file) {
|
|
55
|
+
try {
|
|
56
|
+
return await git.show([`:${stage}:${file}`]);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
// Only a genuinely absent stage means "this side deleted the file". Any
|
|
60
|
+
// other failure (spawn error, repo corruption) must abort resolution —
|
|
61
|
+
// treating it as a deletion would silently drop that side's content.
|
|
62
|
+
if (/not at stage|does not exist/i.test(String(err)))
|
|
63
|
+
return null;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a conflicted in-progress rebase by semantically merging each
|
|
69
|
+
* conflicting vault file and continuing the rebase — never by resetting.
|
|
70
|
+
* Every non-conflicting local commit is replayed untouched, so nothing
|
|
71
|
+
* outside the conflicting files can be lost.
|
|
72
|
+
*
|
|
73
|
+
* On failure (non-vault conflicts, malformed content, unexpected git error)
|
|
74
|
+
* the rebase is aborted, which restores the local branch exactly as it was
|
|
75
|
+
* before the pull. Returns the conflicting files either way, for error messages.
|
|
76
|
+
*/
|
|
77
|
+
async function resolveRebaseConflicts(git, vaultPath) {
|
|
78
|
+
let lastFiles = [];
|
|
79
|
+
// Only rebase conflicts are resolvable here. A merge (MERGE_HEAD) can only
|
|
80
|
+
// come from an interrupted non-rebase pull, which hafez never runs — abort it.
|
|
81
|
+
if (isMergeInProgress(vaultPath)) {
|
|
82
|
+
await ensureCleanState(git, vaultPath);
|
|
83
|
+
return { resolved: false, files: [] };
|
|
62
84
|
}
|
|
63
|
-
// Get remote content and merge — all done BEFORE any destructive ops
|
|
64
|
-
const mergedContents = new Map();
|
|
65
85
|
try {
|
|
66
|
-
for (
|
|
67
|
-
|
|
86
|
+
for (let step = 0; step < MAX_REBASE_STEPS && isRebaseInProgress(vaultPath); step++) {
|
|
87
|
+
const conflictFiles = await getConflictingFiles(git);
|
|
88
|
+
if (conflictFiles.length > 0)
|
|
89
|
+
lastFiles = conflictFiles;
|
|
90
|
+
if (!conflictFiles.every(f => VAULT_FILE_RE.test(f))) {
|
|
91
|
+
await ensureCleanState(git, vaultPath);
|
|
92
|
+
return { resolved: false, files: conflictFiles };
|
|
93
|
+
}
|
|
94
|
+
if (conflictFiles.length > 0) {
|
|
95
|
+
logMerge(`conflict detected, attempting semantic merge for: ${conflictFiles.join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
for (const file of conflictFiles) {
|
|
98
|
+
// During a rebase, stage 2 ("ours") is the upstream/remote side and
|
|
99
|
+
// stage 3 ("theirs") is the local commit being replayed.
|
|
100
|
+
const remoteContent = await showStage(git, 2, file);
|
|
101
|
+
const localContent = await showStage(git, 3, file);
|
|
102
|
+
if (remoteContent === null && localContent === null) {
|
|
103
|
+
// Deleted on both sides — accept the deletion
|
|
104
|
+
await git.raw(['rm', '--force', '--ignore-unmatch', '--', file]);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const merged = localContent === null ? remoteContent
|
|
108
|
+
: remoteContent === null ? localContent
|
|
109
|
+
: mergeVaultContent(remoteContent, localContent);
|
|
110
|
+
const fullPath = join(vaultPath, file);
|
|
111
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
112
|
+
writeFileSync(fullPath, merged);
|
|
113
|
+
await git.add([file]);
|
|
114
|
+
}
|
|
115
|
+
// Decide structurally whether this step still has anything to commit:
|
|
116
|
+
// no staged paths means the merged result is identical to the already-
|
|
117
|
+
// applied tree, so the replayed commit became empty and must be skipped.
|
|
118
|
+
// Output-based on purpose — never `--quiet` exit codes (simple-git
|
|
119
|
+
// swallows diff's silent exit 1) and never git's error prose (localized,
|
|
120
|
+
// and it quotes commit subjects; a false "empty" match would skip a
|
|
121
|
+
// real commit — the COR-1 bug class again).
|
|
122
|
+
const staged = (await git.raw(['diff', '--cached', '--name-only'])).trim();
|
|
123
|
+
const stepEmpty = staged.length === 0;
|
|
68
124
|
try {
|
|
69
|
-
|
|
125
|
+
// Non-interactive: --continue reuses the replayed commit's message
|
|
126
|
+
await git.rebase([stepEmpty ? '--skip' : '--continue']);
|
|
70
127
|
}
|
|
71
|
-
catch {
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
128
|
+
catch (err) {
|
|
129
|
+
// --continue/--skip exit non-zero when the rebase stops again on the
|
|
130
|
+
// NEXT local commit's conflict. That is progress, not failure — the
|
|
131
|
+
// loop resolves the new conflicts on its next pass. Anything else
|
|
132
|
+
// (stuck with nothing to resolve) aborts via the outer catch.
|
|
133
|
+
const nextConflicts = await getConflictingFiles(git);
|
|
134
|
+
if (!isRebaseInProgress(vaultPath) || nextConflicts.length === 0) {
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
75
137
|
}
|
|
76
|
-
mergedContents.set(file, mergeVaultContent(remoteContent, localContents.get(file)));
|
|
77
138
|
}
|
|
139
|
+
if (isRebaseInProgress(vaultPath)) {
|
|
140
|
+
// Step budget exhausted — bail out safely
|
|
141
|
+
await ensureCleanState(git, vaultPath);
|
|
142
|
+
return { resolved: false, files: lastFiles };
|
|
143
|
+
}
|
|
144
|
+
if (lastFiles.length > 0)
|
|
145
|
+
logMerge(`semantic merge succeeded for: ${lastFiles.join(', ')}`);
|
|
146
|
+
return { resolved: true, files: lastFiles };
|
|
78
147
|
}
|
|
79
|
-
catch {
|
|
80
|
-
|
|
81
|
-
|
|
148
|
+
catch (err) {
|
|
149
|
+
logMerge(`semantic merge failed, restoring local state: ${err}`);
|
|
150
|
+
try {
|
|
151
|
+
await ensureCleanState(git, vaultPath);
|
|
152
|
+
}
|
|
153
|
+
catch { /* last resort failed */ }
|
|
154
|
+
return { resolved: false, files: lastFiles };
|
|
82
155
|
}
|
|
83
|
-
|
|
84
|
-
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* One `pull --rebase` attempt, resolving vault-file conflicts inside the
|
|
159
|
+
* rebase. Throws HafezError when a conflict cannot be auto-resolved (the
|
|
160
|
+
* repo is left clean, local commits intact); rethrows the raw git error on
|
|
161
|
+
* transient failures so the caller can retry.
|
|
162
|
+
*/
|
|
163
|
+
async function pullResolving(git, vaultPath, conflictMessage) {
|
|
85
164
|
try {
|
|
86
|
-
await git.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
165
|
+
await git.pull('origin', undefined, { '--rebase': null });
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (isRebaseInProgress(vaultPath) || isMergeInProgress(vaultPath)) {
|
|
169
|
+
const { resolved, files } = await resolveRebaseConflicts(git, vaultPath);
|
|
170
|
+
if (resolved)
|
|
171
|
+
return;
|
|
172
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
173
|
+
throw new HafezError('GIT_PUSH_FAILED', conflictMessage(files), [detail]);
|
|
91
174
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (status.staged.length === 0) {
|
|
95
|
-
// Contents identical after merge — nothing to commit, but we're on remote now
|
|
96
|
-
return true;
|
|
175
|
+
try {
|
|
176
|
+
await ensureCleanState(git, vaultPath);
|
|
97
177
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return true;
|
|
178
|
+
catch { /* preserve error */ }
|
|
179
|
+
throw err;
|
|
101
180
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Push local commits, pulling with rebase and resolving vault-file conflicts
|
|
184
|
+
* semantically when the push is rejected. Push-first: callers either just
|
|
185
|
+
* pulled (gitSync) or usually have a current remote view, so the extra
|
|
186
|
+
* round-trip only happens when the remote actually moved. Shared by gitSync
|
|
187
|
+
* and gitCommitAndPush — this is the single code path that decides whether
|
|
188
|
+
* local data survives a conflict.
|
|
189
|
+
*/
|
|
190
|
+
async function pushResolving(git, vaultPath, conflictMessage, exhaustedMessage) {
|
|
191
|
+
let lastDetail = '';
|
|
192
|
+
for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
|
|
105
193
|
try {
|
|
106
|
-
await git.
|
|
194
|
+
await git.push();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
catch (pushErr) {
|
|
198
|
+
lastDetail = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
199
|
+
// Rejected (remote moved) or transient — integrate remote, then retry
|
|
200
|
+
try {
|
|
201
|
+
await pullResolving(git, vaultPath, conflictMessage);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
if (err instanceof HafezError)
|
|
205
|
+
throw err;
|
|
206
|
+
lastDetail = err instanceof Error ? err.message : String(err);
|
|
207
|
+
}
|
|
208
|
+
if (attempt === MAX_PUSH_RETRIES) {
|
|
209
|
+
throw new HafezError('GIT_PUSH_FAILED', exhaustedMessage, [lastDetail]);
|
|
210
|
+
}
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
|
|
107
212
|
}
|
|
108
|
-
catch { /* last resort failed */ }
|
|
109
|
-
return false;
|
|
110
213
|
}
|
|
111
214
|
}
|
|
112
215
|
/**
|
|
@@ -121,40 +224,20 @@ export async function gitSync(vaultPath) {
|
|
|
121
224
|
// Clean up any stuck rebase/merge from a previous crash.
|
|
122
225
|
// Safe to abort without saving files — all work is already committed.
|
|
123
226
|
await ensureCleanState(git, vaultPath);
|
|
227
|
+
const syncConflictMessage = (files) => `Git conflict during sync (files: ${files.join(', ')}). Changes saved locally.`;
|
|
124
228
|
let pulled = false;
|
|
125
229
|
// Always pull first (bidirectional sync)
|
|
126
230
|
const headBefore = (await git.revparse(['HEAD'])).trim();
|
|
127
231
|
for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
|
|
128
232
|
try {
|
|
129
|
-
await git
|
|
233
|
+
await pullResolving(git, vaultPath, syncConflictMessage);
|
|
130
234
|
const headAfter = (await git.revparse(['HEAD'])).trim();
|
|
131
235
|
pulled = headAfter !== headBefore;
|
|
132
236
|
break;
|
|
133
237
|
}
|
|
134
238
|
catch (err) {
|
|
135
|
-
|
|
136
|
-
|
|
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 */ }
|
|
239
|
+
if (err instanceof HafezError)
|
|
240
|
+
throw err;
|
|
158
241
|
if (attempt === MAX_PUSH_RETRIES) {
|
|
159
242
|
const detail = err instanceof Error ? err.message : String(err);
|
|
160
243
|
throw new HafezError('GIT_PUSH_FAILED', `Git pull failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
|
|
@@ -166,46 +249,20 @@ export async function gitSync(vaultPath) {
|
|
|
166
249
|
const ahead = await git.raw(['rev-list', '--count', '@{u}..HEAD']).then(s => parseInt(s.trim(), 10));
|
|
167
250
|
if (ahead === 0)
|
|
168
251
|
return { pulled, pushed: false, remote: true };
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
}
|
|
252
|
+
await pushResolving(git, vaultPath, syncConflictMessage, `Git push failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`);
|
|
253
|
+
return { pulled, pushed: true, remote: true };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
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.
|
|
260
|
+
*/
|
|
261
|
+
export async function gitUnstage(vaultPath, files) {
|
|
262
|
+
try {
|
|
263
|
+
await simpleGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
|
|
207
264
|
}
|
|
208
|
-
|
|
265
|
+
catch { /* best effort — rollback must not mask the original error */ }
|
|
209
266
|
}
|
|
210
267
|
/** Get list of files with unresolved conflicts */
|
|
211
268
|
async function getConflictingFiles(git) {
|
|
@@ -294,62 +351,17 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
|
|
|
294
351
|
const status = await git.status();
|
|
295
352
|
if (status.staged.length === 0)
|
|
296
353
|
return;
|
|
297
|
-
await git.commit(message);
|
|
354
|
+
const committed = await git.commit(message);
|
|
355
|
+
// simple-git swallows some commit failures (e.g. a rejected pre-commit
|
|
356
|
+
// hook) into an empty result instead of throwing. Staged content with no
|
|
357
|
+
// commit hash means the commit did NOT land — surface it so callers
|
|
358
|
+
// (batch rollback in particular) treat it as a commit-stage failure.
|
|
359
|
+
if (!committed.commit) {
|
|
360
|
+
throw new HafezError('GIT_COMMIT_FAILED', `git commit produced no commit for: ${files.join(', ')}`);
|
|
361
|
+
}
|
|
298
362
|
if (config.push === false)
|
|
299
363
|
return;
|
|
300
364
|
if (!(await hasOriginRemote(git)))
|
|
301
365
|
return;
|
|
302
|
-
|
|
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
|
-
}
|
|
366
|
+
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.`);
|
|
355
367
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { parseFilePath, serializeFile, slugify, resolveFilePath } from './vault.js';
|
|
3
|
-
import { gitCommitAndPush, gitSync, gitChangelog } from './git.js';
|
|
3
|
+
import { gitCommitAndPush, gitSync, gitChangelog, gitUnstage } from './git.js';
|
|
4
4
|
import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
|
|
5
5
|
import { bodyTemplate, knowledgeBodyTemplate } from './templates.js';
|
|
6
6
|
import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry } from './parser.js';
|
|
@@ -12,15 +12,41 @@ import { generateVaultIndex } from './knowledge-index.js';
|
|
|
12
12
|
import { HafezError } from './types.js';
|
|
13
13
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, unlinkSync } from 'fs';
|
|
14
14
|
import { join, relative, basename } from 'path';
|
|
15
|
+
import { lock as lockVault } from 'proper-lockfile';
|
|
15
16
|
export function createHafez(config) {
|
|
16
17
|
const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
|
|
17
|
-
//
|
|
18
|
+
// Two-level write lock: an in-process promise chain serializes calls within
|
|
19
|
+
// this instance, then a cross-process advisory lock (proper-lockfile, mkdir
|
|
20
|
+
// based — works on network/drvfs mounts) serializes against other hafez
|
|
21
|
+
// processes sharing the vault (concurrent CLIs, a bot, another agent).
|
|
18
22
|
let lockPromise = Promise.resolve();
|
|
19
23
|
function withLock(fn) {
|
|
20
24
|
const prev = lockPromise;
|
|
21
25
|
let release;
|
|
22
26
|
lockPromise = new Promise(resolve => { release = resolve; });
|
|
23
|
-
return prev.then(fn).finally(() => release());
|
|
27
|
+
return prev.then(() => withVaultLock(fn)).finally(() => release());
|
|
28
|
+
}
|
|
29
|
+
async function withVaultLock(fn) {
|
|
30
|
+
let releaseVault = null;
|
|
31
|
+
try {
|
|
32
|
+
releaseVault = await lockVault(vaultPath, {
|
|
33
|
+
lockfilePath: join(vaultPath, '.hafez.lock'),
|
|
34
|
+
stale: 30_000,
|
|
35
|
+
retries: { retries: 20, minTimeout: 100, maxTimeout: 1_000, randomize: true },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new HafezError('VAULT_LOCKED', 'Vault is locked by another hafez process. Retry when it finishes.', [err instanceof Error ? err.message : String(err)]);
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return await fn();
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
try {
|
|
46
|
+
await releaseVault();
|
|
47
|
+
}
|
|
48
|
+
catch { /* stale lock reclaimed by another process */ }
|
|
49
|
+
}
|
|
24
50
|
}
|
|
25
51
|
function entityPath(slug) { return resolveFilePath(vaultPath, slug, 'entity'); }
|
|
26
52
|
function knowledgePath(slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
|
|
@@ -33,12 +59,22 @@ export function createHafez(config) {
|
|
|
33
59
|
return kp;
|
|
34
60
|
return null;
|
|
35
61
|
}
|
|
36
|
-
function exists(slug) {
|
|
62
|
+
function exists(slug) {
|
|
63
|
+
// An invalid slug definitionally doesn't exist — reference checks (create
|
|
64
|
+
// parent/related, link targets, validate) must report "does not exist",
|
|
65
|
+
// not crash. validate() in particular exists to REPORT malformed refs.
|
|
66
|
+
try {
|
|
67
|
+
return findFile(slug) !== null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
37
73
|
function today() { return new Date().toISOString().slice(0, 10); }
|
|
38
74
|
function kindFromPath(filePath) {
|
|
39
75
|
return filePath.includes('/entities/') ? 'entity' : 'knowledge';
|
|
40
76
|
}
|
|
41
|
-
const ENTITY_ONLY_FIELDS = ['status', '
|
|
77
|
+
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
|
|
42
78
|
const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
|
|
43
79
|
function validateKindFields(kind, fields) {
|
|
44
80
|
if (kind === 'knowledge') {
|
|
@@ -159,12 +195,6 @@ export function createHafez(config) {
|
|
|
159
195
|
function applyUpdateFields(fm, body, fields) {
|
|
160
196
|
if (fields.status !== undefined)
|
|
161
197
|
fm.status = fields.status;
|
|
162
|
-
if (fields.next_action !== undefined) {
|
|
163
|
-
if (fields.next_action === null)
|
|
164
|
-
delete fm['next-action'];
|
|
165
|
-
else
|
|
166
|
-
fm['next-action'] = fields.next_action;
|
|
167
|
-
}
|
|
168
198
|
if (fields.current_state !== undefined) {
|
|
169
199
|
body = replaceSection(body, '## Current State', fields.current_state, '## Session Log');
|
|
170
200
|
}
|
|
@@ -346,7 +376,6 @@ export function createHafez(config) {
|
|
|
346
376
|
const kind = kindFromPath(filePath);
|
|
347
377
|
validateKindFields(kind, fields);
|
|
348
378
|
let { frontmatter: fm, body } = parseFilePath(filePath);
|
|
349
|
-
const previousNextAction = fm['next-action'] ?? null;
|
|
350
379
|
const applied = applyUpdateFields(fm, body, fields);
|
|
351
380
|
fm = applied.fm;
|
|
352
381
|
body = applied.body;
|
|
@@ -371,7 +400,7 @@ export function createHafez(config) {
|
|
|
371
400
|
getIndex().upsertFromFile(slug, kind);
|
|
372
401
|
regenerateIndexSafe();
|
|
373
402
|
onWrite?.(slug, 'update');
|
|
374
|
-
return {
|
|
403
|
+
return { matched_action: applied.matchedAction };
|
|
375
404
|
});
|
|
376
405
|
}
|
|
377
406
|
async function link(slug, target, relation) {
|
|
@@ -618,8 +647,7 @@ export function createHafez(config) {
|
|
|
618
647
|
}
|
|
619
648
|
// Single git commit for all batch operations
|
|
620
649
|
const uniqueFiles = [...new Set(modifiedFiles)];
|
|
621
|
-
|
|
622
|
-
await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
|
|
650
|
+
const indexAfterBatch = () => {
|
|
623
651
|
for (const slug of affectedSlugs) {
|
|
624
652
|
const fp = findFile(slug);
|
|
625
653
|
if (fp) {
|
|
@@ -633,6 +661,38 @@ export function createHafez(config) {
|
|
|
633
661
|
}
|
|
634
662
|
for (const slug of affectedSlugs)
|
|
635
663
|
onWrite?.(slug, 'batch');
|
|
664
|
+
};
|
|
665
|
+
if (uniqueFiles.length > 0) {
|
|
666
|
+
try {
|
|
667
|
+
await gitCommitAndPush(vaultPath, uniqueFiles, `batch: ${operations.length} operations`, gitConfig);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
|
|
671
|
+
// Commit-stage failure (e.g. a raced .git/index.lock): nothing was
|
|
672
|
+
// committed — restore the captured originals so files, git, and
|
|
673
|
+
// index stay consistent instead of leaving the batch half-applied.
|
|
674
|
+
for (const [path, content] of originals) {
|
|
675
|
+
if (content === null) {
|
|
676
|
+
try {
|
|
677
|
+
unlinkSync(path);
|
|
678
|
+
}
|
|
679
|
+
catch { }
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
writeFileSync(path, content);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Also clear anything the failed commit left staged, or the next
|
|
686
|
+
// commit would publish the rolled-back content.
|
|
687
|
+
await gitUnstage(vaultPath, uniqueFiles);
|
|
688
|
+
throw err;
|
|
689
|
+
}
|
|
690
|
+
// Push-stage failure: the batch commit exists locally ("changes
|
|
691
|
+
// saved locally") — keep it and index it, then surface the error.
|
|
692
|
+
indexAfterBatch();
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
indexAfterBatch();
|
|
636
696
|
}
|
|
637
697
|
return results;
|
|
638
698
|
});
|
|
@@ -822,7 +882,8 @@ export function createHafez(config) {
|
|
|
822
882
|
catch (err) {
|
|
823
883
|
if (target === 'knowledge' && originalContent) {
|
|
824
884
|
writeFileSync(filePath, originalContent);
|
|
825
|
-
|
|
885
|
+
// Never unlink the file we just restored (SEC-4)
|
|
886
|
+
if (knowledgeTarget && knowledgeTarget !== filePath && existsSync(knowledgeTarget))
|
|
826
887
|
unlinkSync(knowledgeTarget);
|
|
827
888
|
}
|
|
828
889
|
throw err;
|
package/dist/merge.js
CHANGED
|
@@ -10,13 +10,17 @@ const ARRAY_FIELDS_UNCAPPED = ['tags', 'domain', 'related'];
|
|
|
10
10
|
export function mergeVaultContent(remote, local) {
|
|
11
11
|
const r = parseContent(remote);
|
|
12
12
|
const l = parseContent(local);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Scalar policy: the side with the newer last-touched wins; local on tie.
|
|
14
|
+
// Append-only sections (Session Log, Next Actions, Evidence, Sources) and
|
|
15
|
+
// array/date/count frontmatter fields are always unioned regardless.
|
|
16
|
+
const localWins = String(l.frontmatter?.['last-touched'] ?? '') >= String(r.frontmatter?.['last-touched'] ?? '');
|
|
17
|
+
const fm = mergeFrontmatter(r.frontmatter, l.frontmatter, localWins);
|
|
18
|
+
const body = mergeBody(r.body, l.body, localWins);
|
|
15
19
|
return serializeFile(fm, body);
|
|
16
20
|
}
|
|
17
|
-
function mergeFrontmatter(remote, local) {
|
|
18
|
-
//
|
|
19
|
-
const merged = { ...remote, ...local };
|
|
21
|
+
function mergeFrontmatter(remote, local, localWins) {
|
|
22
|
+
// Overlay the winning side's scalars on top of the other's
|
|
23
|
+
const merged = localWins ? { ...remote, ...local } : { ...local, ...remote };
|
|
20
24
|
// Date fields: latest wins
|
|
21
25
|
for (const key of DATE_FIELDS) {
|
|
22
26
|
const r = remote[key], l = local[key];
|
|
@@ -64,12 +68,12 @@ function splitSections(body) {
|
|
|
64
68
|
}
|
|
65
69
|
return { preamble, sections };
|
|
66
70
|
}
|
|
67
|
-
function mergeBody(remoteBody, localBody) {
|
|
71
|
+
function mergeBody(remoteBody, localBody, localWins) {
|
|
68
72
|
if (remoteBody === localBody)
|
|
69
73
|
return localBody;
|
|
70
74
|
const remote = splitSections(remoteBody);
|
|
71
75
|
const local = splitSections(localBody);
|
|
72
|
-
// Build merged sections starting from local
|
|
76
|
+
// Build merged sections starting from local (local section order is canonical)
|
|
73
77
|
const localHeadings = new Set(local.sections.map(s => s.heading));
|
|
74
78
|
const mergedSections = [];
|
|
75
79
|
for (const ls of local.sections) {
|
|
@@ -80,8 +84,15 @@ function mergeBody(remoteBody, localBody) {
|
|
|
80
84
|
else if (ls.heading === '## Next Actions' && rs) {
|
|
81
85
|
mergedSections.push({ heading: ls.heading, content: mergeNextActions(rs.content, ls.content) });
|
|
82
86
|
}
|
|
87
|
+
else if ((ls.heading === '## Evidence' || ls.heading === '## Sources') && rs) {
|
|
88
|
+
// Append-only sections: union lines from both sides, dedup identical
|
|
89
|
+
mergedSections.push({ heading: ls.heading, content: mergeAppendOnlyLines(rs.content, ls.content) });
|
|
90
|
+
}
|
|
91
|
+
else if (rs && !localWins) {
|
|
92
|
+
// Scalar sections (Brief, Current State, Synthesis, ...): newer side wins
|
|
93
|
+
mergedSections.push(rs);
|
|
94
|
+
}
|
|
83
95
|
else {
|
|
84
|
-
// Brief, Current State, anything else: local wins
|
|
85
96
|
mergedSections.push(ls);
|
|
86
97
|
}
|
|
87
98
|
}
|
|
@@ -92,10 +103,26 @@ function mergeBody(remoteBody, localBody) {
|
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
105
|
// Reassemble
|
|
95
|
-
const preamble =
|
|
106
|
+
const preamble = localWins
|
|
107
|
+
? (local.preamble || remote.preamble)
|
|
108
|
+
: (remote.preamble || local.preamble);
|
|
96
109
|
const parts = [preamble, ...mergedSections.map(s => `${s.heading}\n\n${s.content}`)].filter(Boolean);
|
|
97
110
|
return parts.join('\n\n');
|
|
98
111
|
}
|
|
112
|
+
/** Union merge for append-only free-text sections: local lines first, then
|
|
113
|
+
* remote lines not already present. Blank lines are kept only from local. */
|
|
114
|
+
function mergeAppendOnlyLines(remoteContent, localContent) {
|
|
115
|
+
if (remoteContent === localContent)
|
|
116
|
+
return localContent;
|
|
117
|
+
const localLines = localContent.split('\n');
|
|
118
|
+
const seen = new Set(localLines.map(l => l.trim()).filter(Boolean));
|
|
119
|
+
const remoteOnly = remoteContent
|
|
120
|
+
.split('\n')
|
|
121
|
+
.filter(l => l.trim() && !seen.has(l.trim()));
|
|
122
|
+
if (remoteOnly.length === 0)
|
|
123
|
+
return localContent;
|
|
124
|
+
return [...localLines, ...remoteOnly].join('\n');
|
|
125
|
+
}
|
|
99
126
|
function mergeSessionLogs(remoteContent, localContent) {
|
|
100
127
|
const remoteEntries = parseLogEntries(remoteContent);
|
|
101
128
|
const localEntries = parseLogEntries(localContent);
|