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/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
- 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'));
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 (const file of files) {
67
- let remoteContent;
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
- remoteContent = await git.show(`origin/${branch}:${file}`);
125
+ // Non-interactive: --continue reuses the replayed commit's message
126
+ await git.rebase([stepEmpty ? '--skip' : '--continue']);
70
127
  }
71
- catch {
72
- // File doesn't exist on remote — no merge needed, keep local
73
- mergedContents.set(file, localContents.get(file));
74
- continue;
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
- // Merge failed (malformed content, etc.) — local commit is still intact
81
- return false;
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
- // --- Destructive section: reset + rewrite ---
84
- // If anything fails below, restore the local commit
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.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);
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
- 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;
175
+ try {
176
+ await ensureCleanState(git, vaultPath);
97
177
  }
98
- await git.commit(message);
99
- logMerge(`semantic merge succeeded for: ${files.join(', ')}`);
100
- return true;
178
+ catch { /* preserve error */ }
179
+ throw err;
101
180
  }
102
- catch (err) {
103
- // Restore local commit — "changes saved locally" must remain true
104
- logMerge(`merge failed after reset, restoring local commit ${localSHA}: ${err}`);
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.reset(['--hard', localSHA]);
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.pull('origin', undefined, { '--rebase': null });
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
- 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 */ }
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
- // 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
- }
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
- return { pulled, pushed: false, remote: true }; // unreachable, but satisfies TS
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
- 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
- }
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
- // Per-instance mutex — each createHafez() gets its own lock
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) { return findFile(slug) !== null; }
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', 'next_action', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
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 { previous_next_action: previousNextAction, matched_action: applied.matchedAction };
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
- if (uniqueFiles.length > 0) {
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
- if (knowledgeTarget && existsSync(knowledgeTarget))
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
- const fm = mergeFrontmatter(r.frontmatter, l.frontmatter);
14
- const body = mergeBody(r.body, l.body);
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
- // Start with remote as base, overlay local (local wins for scalars)
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 = local.preamble || remote.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);