hafez 1.1.0 → 1.3.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.
@@ -0,0 +1,108 @@
1
+ import type { SessionLogEntry, SessionLogType } from './types.js';
2
+ /**
3
+ * Known structural section headings in Hafez entities, knowledge notes, and
4
+ * session files. Derived from contracts.ts (defaultSections + optionalSections
5
+ * + session sections) plus auto-generated sections not tracked in contracts
6
+ * (Related). Only these are treated as section boundaries. Any other `## `
7
+ * heading inside section content (e.g. in a brief or session log) is
8
+ * preserved as-is.
9
+ */
10
+ export declare const STRUCTURAL_SECTIONS: Set<string>;
11
+ /**
12
+ * Find the next structural `## Heading` boundary after a given position.
13
+ * Skips `## ` headings that are not known structural sections (content headings).
14
+ * Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
15
+ */
16
+ export declare function findNextStructuralHeading(body: string, from: number): number;
17
+ /**
18
+ * Find a ## Heading section and its content.
19
+ * Returns { start, headingEnd, end, content } or null if not found.
20
+ * Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
21
+ * Trailing whitespace on the heading line is tolerated — the splitter's
22
+ * boundary tokenizer trims headings, so anything it treats as a boundary
23
+ * must also be findable here (a hand-edited `## Session Log ` otherwise
24
+ * gets a duplicate section and archival never fires).
25
+ * Only treats known structural headings as section boundaries -- content
26
+ * inside a section may contain arbitrary `## ` headings without corruption.
27
+ */
28
+ export declare function findSection(body: string, heading: string): {
29
+ start: number;
30
+ headingEnd: number;
31
+ end: number;
32
+ content: string;
33
+ } | null;
34
+ export interface DocSection {
35
+ heading: string;
36
+ content: string;
37
+ }
38
+ /**
39
+ * Split a body into preamble + structural sections. Non-structural `## `
40
+ * headings stay inside the enclosing section's content — sections are
41
+ * atomic units of authored content, never split at user headings.
42
+ * Headings and content come back trimmed; reassemble with
43
+ * `## ${heading}\n\n${content}` joined by blank lines.
44
+ */
45
+ export declare function splitStructural(body: string): {
46
+ preamble: string;
47
+ sections: DocSection[];
48
+ };
49
+ /**
50
+ * Replace a structural section's content, or create the section: before
51
+ * `insertBefore` when that section exists, at EOF otherwise.
52
+ */
53
+ export declare function setSection(body: string, heading: string, content: string, insertBefore?: string): string;
54
+ /** Append content at the end of a structural section (creating it at EOF if absent). */
55
+ export declare function appendToSection(body: string, heading: string, content: string): string;
56
+ /** Insert a formatted session-log entry newest-first (creating the section at EOF if absent). */
57
+ export declare function prependSessionLogEntry(body: string, entry: string): string;
58
+ /** Body up to the real Session Log section — the `summary` read depth. */
59
+ export declare function bodyBeforeSessionLog(body: string): string;
60
+ export declare function parseSessionLogHeading(line: string): {
61
+ date: string;
62
+ agent: string;
63
+ type: SessionLogType;
64
+ } | null;
65
+ export declare function formatSessionLogEntry(entry: SessionLogEntry): string;
66
+ /** Entries inside the real Session Log section only — entry-shaped lines
67
+ * elsewhere in the body (e.g. quoted in a Brief) do not count. */
68
+ export declare function countSessionLogEntries(body: string): number;
69
+ export declare function parseSessionLog(body: string): Array<{
70
+ date: string;
71
+ agent: string;
72
+ type: SessionLogType;
73
+ summary: string;
74
+ }>;
75
+ export declare function extractOldestSessionLogEntry(body: string): {
76
+ entry: string;
77
+ remaining: string;
78
+ } | null;
79
+ export declare function getBrief(body: string): string | null;
80
+ export declare function setBrief(body: string, content: string): string;
81
+ export declare function removeBrief(body: string): string;
82
+ export declare function getNextActions(body: string): string[];
83
+ export declare function addNextAction(body: string, action: string): string;
84
+ export declare function completeNextAction(body: string, query: string): {
85
+ matched: string;
86
+ body: string;
87
+ };
88
+ export declare function removeNextAction(body: string, query: string): {
89
+ matched: string;
90
+ body: string;
91
+ };
92
+ export declare function clearNextActions(body: string): string;
93
+ export declare function firstNonEmptyLine(s: string | null | undefined): string;
94
+ /**
95
+ * One-line summary for a doc: frontmatter description first, then the first
96
+ * non-empty line of the kind's primary section (entity: Brief; knowledge:
97
+ * Synthesis, falling back to Goal for plans; session: Summary).
98
+ * Shared by the vault index and the OKF export so the two surfaces agree.
99
+ */
100
+ export declare function deriveSummary(kind: 'entity' | 'knowledge' | 'session', fm: Record<string, any>, body: string): string;
101
+ /** The one wiki-link syntax the vault recognizes: [[slug]] or
102
+ * [[slug|display text]] — slug in group 1, display text in group 2.
103
+ * Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
104
+ export declare const WIKI_LINK_RE: RegExp;
105
+ export declare function syncRelatedSection(body: string, frontmatter: {
106
+ parent?: string;
107
+ related?: string[];
108
+ }): string;
@@ -1,19 +1,23 @@
1
- // src/sections.ts
2
- // Pure string→string functions for Brief and Next Actions section manipulation.
3
- import { ALL_CONTRACTS, SUBTYPE_SECTIONS } from './contracts.js';
1
+ // src/document.ts
2
+ // The Document module (see CONTEXT.md): owns the body of a vault file —
3
+ // structural sections, session-log entries, wiki-links. Absorbs the former
4
+ // sections.ts and parser.ts; every body read or mutation goes through the
5
+ // single structural-heading model defined here. Pure string→string.
6
+ import { ALL_CONTRACTS, SUBTYPE_SECTIONS, SESSION_SECTIONS } from './contracts.js';
4
7
  const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
5
8
  /**
6
- * Known structural section headings in Hafez entities and knowledge notes.
7
- * Derived from contracts.ts (defaultSections + optionalSections) plus
8
- * auto-generated sections not tracked in contracts (Related, Summary).
9
- * Only these are treated as section boundaries. Any other `## ` heading
10
- * inside section content (e.g. in a brief or session log) is preserved as-is.
9
+ * Known structural section headings in Hafez entities, knowledge notes, and
10
+ * session files. Derived from contracts.ts (defaultSections + optionalSections
11
+ * + session sections) plus auto-generated sections not tracked in contracts
12
+ * (Related). Only these are treated as section boundaries. Any other `## `
13
+ * heading inside section content (e.g. in a brief or session log) is
14
+ * preserved as-is.
11
15
  */
12
16
  export const STRUCTURAL_SECTIONS = new Set([
13
17
  ...ALL_CONTRACTS.flatMap(c => [...c.defaultSections, ...c.optionalSections]),
14
18
  ...Object.values(SUBTYPE_SECTIONS).flat(),
19
+ ...SESSION_SECTIONS,
15
20
  'Related', // auto-generated by syncRelatedSection
16
- 'Summary', // used in session files
17
21
  ]);
18
22
  /**
19
23
  * Find the next structural `## Heading` boundary after a given position.
@@ -32,31 +36,176 @@ export function findNextStructuralHeading(body, from) {
32
36
  }
33
37
  return -1;
34
38
  }
35
- // --- Internal helpers ---
36
39
  /**
37
40
  * Find a ## Heading section and its content.
38
- * Returns { start, end, content } or null if not found.
41
+ * Returns { start, headingEnd, end, content } or null if not found.
39
42
  * Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
43
+ * Trailing whitespace on the heading line is tolerated — the splitter's
44
+ * boundary tokenizer trims headings, so anything it treats as a boundary
45
+ * must also be findable here (a hand-edited `## Session Log ` otherwise
46
+ * gets a duplicate section and archival never fires).
40
47
  * Only treats known structural headings as section boundaries -- content
41
48
  * inside a section may contain arbitrary `## ` headings without corruption.
42
49
  */
43
50
  export function findSection(body, heading) {
44
- const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)})(?:\\n|$)`);
51
+ const re = new RegExp(`(^|\\n)(## ${escapeRegex(heading)}[ \\t]*)(?:\\n|$)`);
45
52
  const match = re.exec(body);
46
53
  if (!match)
47
54
  return null;
48
55
  const start = match.index + match[1].length;
49
- const afterHeading = start + match[2].length + 1;
50
- // Find the next structural ## heading or EOF
51
- const nextHeading = findNextStructuralHeading(body, afterHeading);
56
+ const headingEnd = start + match[2].length;
57
+ const afterHeading = headingEnd + 1;
58
+ // Find the next structural ## heading or EOF. Scan from the heading's own
59
+ // trailing newline (afterHeading - 1), not past it — otherwise a structural
60
+ // heading on the immediately following line (no blank line between, as in
61
+ // hand-edited files) is invisible and mutators swallow the next section.
62
+ const nextHeading = findNextStructuralHeading(body, Math.max(0, afterHeading - 1));
52
63
  const end = nextHeading === -1 ? body.length : nextHeading;
53
64
  const rawContent = body.slice(afterHeading, end);
54
65
  const content = rawContent.replace(/^\n+/, '').replace(/\n+$/, '');
55
- return { start, end, content };
66
+ return { start, headingEnd, end, content };
56
67
  }
57
68
  function escapeRegex(s) {
58
69
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
70
  }
71
+ /**
72
+ * Split a body into preamble + structural sections. Non-structural `## `
73
+ * headings stay inside the enclosing section's content — sections are
74
+ * atomic units of authored content, never split at user headings.
75
+ * Headings and content come back trimmed; reassemble with
76
+ * `## ${heading}\n\n${content}` joined by blank lines.
77
+ */
78
+ export function splitStructural(body) {
79
+ const marks = [];
80
+ const re = /(^|\n)## ([^\n]*)/g;
81
+ let m;
82
+ while ((m = re.exec(body)) !== null) {
83
+ const heading = m[2].trim();
84
+ if (!STRUCTURAL_SECTIONS.has(heading))
85
+ continue;
86
+ marks.push({ start: m.index + m[1].length, contentStart: m.index + m[0].length, heading });
87
+ }
88
+ if (marks.length === 0)
89
+ return { preamble: body.trim(), sections: [] };
90
+ const preamble = body.slice(0, marks[0].start).trim();
91
+ const sections = marks.map((mk, i) => ({
92
+ heading: mk.heading,
93
+ content: body.slice(mk.contentStart, i + 1 < marks.length ? marks[i + 1].start : body.length).trim(),
94
+ }));
95
+ return { preamble, sections };
96
+ }
97
+ // --- Generic section mutation ---
98
+ /**
99
+ * Replace a structural section's content, or create the section: before
100
+ * `insertBefore` when that section exists, at EOF otherwise.
101
+ */
102
+ export function setSection(body, heading, content, insertBefore) {
103
+ const existing = findSection(body, heading);
104
+ if (existing) {
105
+ return body.slice(0, existing.start) + `## ${heading}\n\n${content}\n` + body.slice(existing.end);
106
+ }
107
+ if (insertBefore) {
108
+ const target = findSection(body, insertBefore);
109
+ if (target) {
110
+ return body.slice(0, target.start) + `## ${heading}\n\n${content}\n\n` + body.slice(target.start);
111
+ }
112
+ }
113
+ return body + `\n\n## ${heading}\n\n${content}\n`;
114
+ }
115
+ /** Append content at the end of a structural section (creating it at EOF if absent). */
116
+ export function appendToSection(body, heading, content) {
117
+ const section = findSection(body, heading);
118
+ if (!section)
119
+ return body + `\n\n## ${heading}\n\n${content}\n`;
120
+ if (section.end === body.length)
121
+ return body + '\n' + content + '\n';
122
+ return body.slice(0, section.end) + '\n' + content + body.slice(section.end);
123
+ }
124
+ /** Insert a formatted session-log entry newest-first (creating the section at EOF if absent). */
125
+ export function prependSessionLogEntry(body, entry) {
126
+ const section = findSection(body, 'Session Log');
127
+ if (!section)
128
+ return body + `\n\n## Session Log\n\n${entry}\n`;
129
+ const insertPos = section.headingEnd;
130
+ return body.slice(0, insertPos) + '\n\n' + entry + '\n' + body.slice(insertPos);
131
+ }
132
+ /** Body up to the real Session Log section — the `summary` read depth. */
133
+ export function bodyBeforeSessionLog(body) {
134
+ const section = findSection(body, 'Session Log');
135
+ return section ? body.slice(0, section.start).trim() : body;
136
+ }
137
+ // --- Session log parsing ---
138
+ const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
139
+ export function parseSessionLogHeading(line) {
140
+ const match = line.match(HEADING_RE);
141
+ if (!match)
142
+ return null;
143
+ return {
144
+ date: match[1],
145
+ agent: match[2],
146
+ type: match[3] || 'progress'
147
+ };
148
+ }
149
+ export function formatSessionLogEntry(entry) {
150
+ const today = new Date().toISOString().slice(0, 10);
151
+ const lines = [`### ${today} — ${entry.agent} [${entry.type}]`, `Summary: ${entry.summary}`];
152
+ if (entry.body)
153
+ lines.push(entry.body);
154
+ return lines.join('\n');
155
+ }
156
+ /** Entries inside the real Session Log section only — entry-shaped lines
157
+ * elsewhere in the body (e.g. quoted in a Brief) do not count. */
158
+ export function countSessionLogEntries(body) {
159
+ const section = findSection(body, 'Session Log');
160
+ if (!section)
161
+ return 0;
162
+ return (section.content.match(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm) || []).length;
163
+ }
164
+ export function parseSessionLog(body) {
165
+ const section = findSection(body, 'Session Log');
166
+ if (!section)
167
+ return [];
168
+ const entries = [];
169
+ const lines = section.content.split('\n');
170
+ let current = null;
171
+ let summaryLine = null;
172
+ for (const line of lines) {
173
+ const heading = parseSessionLogHeading(line);
174
+ if (heading) {
175
+ if (current && summaryLine) {
176
+ entries.push({ ...current, summary: summaryLine });
177
+ }
178
+ current = heading;
179
+ summaryLine = null;
180
+ }
181
+ else if (current && !summaryLine && line.startsWith('Summary: ')) {
182
+ summaryLine = line.slice('Summary: '.length);
183
+ }
184
+ }
185
+ if (current && summaryLine) {
186
+ entries.push({ ...current, summary: summaryLine });
187
+ }
188
+ return entries;
189
+ }
190
+ export function extractOldestSessionLogEntry(body) {
191
+ const section = findSection(body, 'Session Log');
192
+ if (!section)
193
+ return null;
194
+ // Work on the raw slice between the heading and the section's structural
195
+ // end, so the extracted entry can never swallow a following section
196
+ // (e.g. an auto-appended Related) into the archive.
197
+ const contentStart = section.headingEnd;
198
+ const region = body.slice(contentStart, section.end);
199
+ const entryStarts = [...region.matchAll(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm)];
200
+ if (entryStarts.length < 2)
201
+ return null;
202
+ // Entries are newest-first in the file, so the oldest is the last match
203
+ const oldestStart = entryStarts[entryStarts.length - 1].index;
204
+ const entry = region.slice(oldestStart).trim();
205
+ const remaining = body.slice(0, contentStart) + region.slice(0, oldestStart) + body.slice(section.end);
206
+ return { entry, remaining };
207
+ }
208
+ // --- Section removal / insertion helpers (Brief, Next Actions) ---
60
209
  /**
61
210
  * Remove a section (heading + content) from body, cleaning up extra whitespace.
62
211
  */
@@ -121,9 +270,9 @@ function insertSectionAfter(body, heading, content, after, beforeFallbacks) {
121
270
  function normalizeForMatch(s) {
122
271
  return s
123
272
  .trim()
124
- .replace(/[\u2018\u2019]/g, "'")
125
- .replace(/[\u201C\u201D]/g, '"')
126
- .replace(/\u2014/g, '-');
273
+ .replace(/[‘’]/g, "'")
274
+ .replace(/[“”]/g, '"')
275
+ .replace(/—/g, '-');
127
276
  }
128
277
  /**
129
278
  * Match an action by case-insensitive substring against unchecked items.
@@ -251,9 +400,11 @@ export function deriveSummary(kind, fm, body) {
251
400
  }
252
401
  return firstNonEmptyLine(findSection(body, 'Summary')?.content);
253
402
  }
254
- // --- Related section functions ---
255
- /** Matches [[slug]] or [[slug|display text]], capturing slug in group 1. Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
256
- export const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/;
403
+ // --- Wiki-links ---
404
+ /** The one wiki-link syntax the vault recognizes: [[slug]] or
405
+ * [[slug|display text]] — slug in group 1, display text in group 2.
406
+ * Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
407
+ export const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/;
257
408
  export function syncRelatedSection(body, frontmatter) {
258
409
  const targets = [];
259
410
  if (frontmatter.parent)
@@ -7,24 +7,12 @@
7
7
  // regenerated indexes) until the bundle directory is deleted and re-exported.
8
8
  import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
9
  import { join, resolve, sep, basename } from 'path';
10
- import { parseFilePath, serializeFile } from './vault.js';
11
- import { deriveSummary } from './sections.js';
12
- const KIND_DIRS = [
13
- { dir: 'entities', kind: 'entity' },
14
- { dir: 'knowledge', kind: 'knowledge' },
15
- { dir: 'sessions', kind: 'session' },
16
- ];
17
- // Local capturing variant of the shared WIKI_LINK_RE — captures display text in
18
- // group 2, which the shared regex's consumers don't need.
19
- const OKF_LINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
20
- function scanDir(dirPath) {
21
- try {
22
- return readdirSync(dirPath).filter(f => f.endsWith('.md')).map(f => join(dirPath, f));
23
- }
24
- catch {
25
- return [];
26
- }
27
- }
10
+ import { parseFilePath, serializeFile, scanVaultDir, kindDir } from './vault.js';
11
+ import { deriveSummary, WIKI_LINK_RE } from './document.js';
12
+ const SOURCE_KINDS = ['entity', 'knowledge', 'session'];
13
+ const KIND_DIRS = SOURCE_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
14
+ // The shared wiki-link syntax (document.ts) — group 1 slug, group 2 display.
15
+ const OKF_LINK_RE = new RegExp(WIKI_LINK_RE.source, 'g');
28
16
  function mapFrontmatter(doc) {
29
17
  const { fm, kind, body } = doc;
30
18
  const mapped = {};
@@ -88,7 +76,7 @@ export function exportOkf(vaultPath, outDir) {
88
76
  // Enumerate and parse. Sessions are not in SQLite — disk enumeration is required.
89
77
  const docs = [];
90
78
  for (const { dir, kind } of KIND_DIRS) {
91
- for (const filePath of scanDir(join(resolvedVault, dir))) {
79
+ for (const filePath of scanVaultDir(join(resolvedVault, dir))) {
92
80
  const slug = basename(filePath, '.md');
93
81
  const relFile = `${dir}/${slug}.md`;
94
82
  let fm, body;
@@ -160,9 +148,9 @@ export function exportOkf(vaultPath, outDir) {
160
148
  }
161
149
  // Root index.md — the only place okf_version may appear
162
150
  const counts = [
163
- ['entities', report.entities],
164
- ['knowledge', report.knowledge],
165
- ['sessions', report.sessions],
151
+ [kindDir('entity'), report.entities],
152
+ [kindDir('knowledge'), report.knowledge],
153
+ [kindDir('session'), report.sessions],
166
154
  ];
167
155
  const rootLines = ['# Vault Export', ''];
168
156
  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,12 +8,18 @@ export declare function gitSync(vaultPath: string): Promise<{
8
8
  pushed: boolean;
9
9
  remote: boolean;
10
10
  }>;
11
+ export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
11
12
  /**
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.
13
+ * Latest git time per file (repo-relative, forward-slash paths). Used to
14
+ * break day-granularity frontmatter ties in stats recents. `modified` maps
15
+ * each file to its most recent commit; `added` to the commit that added it.
16
+ * Files with no history (not yet committed) are absent from the map.
16
17
  */
17
- export declare function gitUnstage(vaultPath: string, files: string[]): Promise<void>;
18
- export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
18
+ export declare function gitFileTimes(vaultPath: string, mode: 'modified' | 'added'): Promise<Map<string, string>>;
19
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,7 @@ 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 } from './vault.js';
6
7
  import { HafezError } from './types.js';
7
8
  const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
8
9
  const MAX_PUSH_RETRIES = 3;
@@ -40,7 +41,6 @@ async function ensureCleanState(git, vaultPath) {
40
41
  }
41
42
  }
42
43
  }
43
- const VAULT_FILE_RE = /^(?:entities|knowledge|sessions)\/.+\.md$/;
44
44
  /**
45
45
  * True if the vault repo has an `origin` remote. A local-only vault (the
46
46
  * default the README setup produces) has none — every pull/push must be
@@ -253,12 +253,13 @@ export async function gitSync(vaultPath) {
253
253
  return { pulled, pushed: true, remote: true };
254
254
  }
255
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.
256
+ * Unstage files (best effort). Used by the journal adapter's commit-failure
257
+ * repair: a commit that fails after `git add` leaves the new blobs staged in
258
+ * .git/index, and the next successful commit would publish rolled-back
259
+ * content under the wrong message. Clearing the index entries restores
260
+ * worktree/HEAD/index agreement.
260
261
  */
261
- export async function gitUnstage(vaultPath, files) {
262
+ async function gitUnstage(vaultPath, files) {
262
263
  try {
263
264
  await simpleGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
264
265
  }
@@ -281,11 +282,16 @@ const FILE_STATUS_MAP = {
281
282
  };
282
283
  export async function gitChangelog(vaultPath, since) {
283
284
  const git = simpleGit(vaultPath);
285
+ // git approxidate fills a date-only --since with the *current* time of
286
+ // day, so `--since <today>` means "since right now" and silently returns
287
+ // nothing — the obvious "what happened today" query was the one that
288
+ // failed. Normalise bare dates to local midnight.
289
+ const normalizedSince = /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since}T00:00:00` : since;
284
290
  let output;
285
291
  try {
286
292
  output = await git.raw([
287
- 'log', `--since=${since}`, '--name-status',
288
- '--pretty=format:%H|%aI|%s', '--', 'entities/', 'knowledge/',
293
+ 'log', `--since=${normalizedSince}`, '--name-status',
294
+ '--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
289
295
  ]);
290
296
  }
291
297
  catch {
@@ -311,24 +317,49 @@ export async function gitChangelog(vaultPath, since) {
311
317
  const operation = FILE_STATUS_MAP[statusChar];
312
318
  if (!operation)
313
319
  continue;
314
- let kind;
315
- let slug;
316
- if (filePath.startsWith('entities/')) {
317
- kind = 'entity';
318
- slug = filePath.replace('entities/', '').replace('.md', '');
319
- }
320
- else if (filePath.startsWith('knowledge/')) {
321
- kind = 'knowledge';
322
- slug = filePath.replace('knowledge/', '').replace('.md', '');
323
- }
324
- else {
320
+ // Only vault documents — scaffold files (.gitkeep) live in the same
321
+ // dirs and would otherwise surface as changelog rows.
322
+ if (!filePath.endsWith('.md'))
325
323
  continue;
326
- }
327
- entries.push({ slug, kind, operation, timestamp, commit_message });
324
+ const classified = classifyIndexedRelPath(filePath);
325
+ if (!classified)
326
+ continue;
327
+ entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
328
328
  }
329
329
  }
330
330
  return entries;
331
331
  }
332
+ /**
333
+ * Latest git time per file (repo-relative, forward-slash paths). Used to
334
+ * break day-granularity frontmatter ties in stats recents. `modified` maps
335
+ * each file to its most recent commit; `added` to the commit that added it.
336
+ * Files with no history (not yet committed) are absent from the map.
337
+ */
338
+ export async function gitFileTimes(vaultPath, mode) {
339
+ const git = simpleGit(vaultPath);
340
+ let output;
341
+ try {
342
+ const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', ...INDEXED_DIR_PATHSPECS];
343
+ if (mode === 'added')
344
+ args.splice(1, 0, '--diff-filter=A');
345
+ output = await git.raw(args);
346
+ }
347
+ catch {
348
+ return new Map(); // no git history
349
+ }
350
+ const times = new Map();
351
+ let current = '';
352
+ for (const line of output.split('\n')) {
353
+ if (line.startsWith('\0')) {
354
+ current = line.slice(1);
355
+ continue;
356
+ }
357
+ const file = line.trim();
358
+ if (file && !times.has(file))
359
+ times.set(file, current); // first hit = newest
360
+ }
361
+ return times;
362
+ }
332
363
  export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
333
364
  const git = simpleGit(vaultPath);
334
365
  // If a previous run crashed mid-rebase/merge, the repo is stuck.
@@ -365,3 +396,30 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
365
396
  return;
366
397
  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.`);
367
398
  }
399
+ /**
400
+ * The production Journal (see CONTEXT.md): vault persistence backed by git.
401
+ * Local-only vaults (no origin remote) commit silently without pushing —
402
+ * that behavior lives in gitCommitAndPush/gitSync, inside this adapter.
403
+ */
404
+ export function createGitJournal(vaultPath, config = {}) {
405
+ return {
406
+ async commit(written, deleted, message) {
407
+ const files = [...written, ...deleted];
408
+ try {
409
+ await gitCommitAndPush(vaultPath, files, message, config);
410
+ }
411
+ catch (err) {
412
+ // Commit-stage failure: nothing landed, but `git add` may have staged
413
+ // the new blobs — clean up our own partial state before rethrowing.
414
+ // Push-stage failure keeps the local commit (GIT_PUSH_FAILED).
415
+ if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
416
+ await gitUnstage(vaultPath, files);
417
+ }
418
+ throw err;
419
+ }
420
+ },
421
+ sync: () => gitSync(vaultPath),
422
+ changelog: (since) => gitChangelog(vaultPath, since),
423
+ fileTimes: (mode) => gitFileTimes(vaultPath, mode),
424
+ };
425
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { Hafez, HafezConfig } from './types.js';
2
2
  export declare function createHafez(config: HafezConfig): Hafez;
3
3
  export { HafezError } from './types.js';
4
- export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
5
- export { getBrief, getNextActions, findSection } from './sections.js';
6
- export { parseSessionLogHeading, parseSessionLog } from './parser.js';
7
- export { formatStats, formatChangelog, formatValidation } from './cli/format.js';
4
+ export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, 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, findSection } from './document.js';
6
+ export { parseSessionLogHeading, parseSessionLog } from './document.js';