hafez 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.js CHANGED
@@ -23,8 +23,9 @@ import { localDay } from './dates.js';
23
23
  // rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
24
24
  // v11: next_action and the searchable body store action text with its
25
25
  // added-date stamp stripped.
26
+ // v12: sessions are indexed (#35); session_date column.
26
27
  // Existing instances will fully rebuild the index on first access after upgrade.
27
- export const SCHEMA_VERSION = '11';
28
+ export const SCHEMA_VERSION = '12';
28
29
  const SCHEMA = `
29
30
  CREATE TABLE IF NOT EXISTS items (
30
31
  slug TEXT PRIMARY KEY,
@@ -50,7 +51,8 @@ CREATE TABLE IF NOT EXISTS items (
50
51
  session_log_count INTEGER DEFAULT 0,
51
52
  last_session_date TEXT,
52
53
  last_session_type TEXT,
53
- last_session_summary TEXT
54
+ last_session_summary TEXT,
55
+ session_date TEXT
54
56
  );
55
57
 
56
58
  CREATE TABLE IF NOT EXISTS links (
@@ -100,8 +102,14 @@ const SORT_COL_MAP = {
100
102
  staleness: 'i.staleness_days',
101
103
  reinforcement_count: 'i.reinforcement_count',
102
104
  last_reinforced: 'i.last_reinforced',
105
+ session_date: 'i.session_date',
106
+ };
107
+ /** Sort fields one branch cannot honour: the column is NULL for its rows. */
108
+ const SORT_REJECTS = {
109
+ entity: new Set(['reinforcement_count', 'last_reinforced', 'session_date']),
110
+ knowledge: new Set(['session_date']),
111
+ session: new Set(['reinforcement_count', 'last_reinforced', 'last_touched', 'staleness']),
103
112
  };
104
- const KNOWLEDGE_ONLY_SORTS = new Set(['reinforcement_count', 'last_reinforced']);
105
113
  const SORT_FIELDS = Object.keys(SORT_COL_MAP);
106
114
  const SORT_ORDERS = ['asc', 'desc'];
107
115
  const QUERY_FILTERS = [...ENTITY_STATUSES, 'all', 'stale', 'capture'];
@@ -163,12 +171,18 @@ function queryItemsFn(db, opts) {
163
171
  conditions.push('i.kind = @kind');
164
172
  params.kind = opts.kind;
165
173
  }
174
+ else if (!opts.kind) {
175
+ // Unset is the working set. Sessions are a record of what happened, not
176
+ // a thing being worked on, and each one names every entity it touched:
177
+ // in the default view they would crowd out the note being looked for.
178
+ conditions.push("i.kind != 'session'");
179
+ }
166
180
  if (opts.status) {
167
- // Status is an entity attribute — knowledge rows are indexed with a NULL
181
+ // Status is an entity attribute — other kinds are indexed with a NULL
168
182
  // status. In a kind-unset (unified) query the status filter narrows the
169
183
  // entity side only; applying it to every row is what made `query --kind
170
184
  // all` (filter defaults to active) return entities and nothing else.
171
- conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind = 'knowledge' OR i.status = @status)");
185
+ conditions.push(opts.kind === 'entity' ? 'i.status = @status' : "(i.kind != 'entity' OR i.status = @status)");
172
186
  params.status = opts.status;
173
187
  }
174
188
  if (opts.type) {
@@ -212,12 +226,14 @@ function queryItemsFn(db, opts) {
212
226
  if (opts.filter === 'capture') {
213
227
  conditions.push("i.type = 'capture'");
214
228
  }
229
+ // A session's one day is its session date: the window reads it where an
230
+ // entity or plan has last_touched.
215
231
  if (opts.since) {
216
- conditions.push('i.last_touched >= @since');
232
+ conditions.push('COALESCE(i.last_touched, i.session_date) >= @since');
217
233
  params.since = opts.since;
218
234
  }
219
235
  if (opts.before) {
220
- conditions.push('i.last_touched <= @before');
236
+ conditions.push('COALESCE(i.last_touched, i.session_date) <= @before');
221
237
  params.before = opts.before;
222
238
  }
223
239
  if (opts.createdSince) {
@@ -242,12 +258,12 @@ function queryItemsFn(db, opts) {
242
258
  assertSortOrder(opts.sort_order);
243
259
  assertCount(opts.limit, 'limit');
244
260
  assertCount(opts.offset, 'offset');
245
- let sortCol = 'i.last_touched';
261
+ let sortCol = opts.kind === 'session' ? 'i.session_date' : 'i.last_touched';
246
262
  if (opts.sort_by) {
247
263
  // Same rule as the unmappable opts, one code path over: a sort field the
248
264
  // branch cannot honour is rejected, not quietly swapped for recency.
249
- if (KNOWLEDGE_ONLY_SORTS.has(opts.sort_by) && opts.kind === 'entity') {
250
- outOfScope('sort_by', opts.sort_by, 'entity');
265
+ if (opts.kind && opts.kind !== 'all' && SORT_REJECTS[opts.kind].has(opts.sort_by)) {
266
+ outOfScope('sort_by', opts.sort_by, opts.kind);
251
267
  }
252
268
  if (SORT_COL_MAP[opts.sort_by])
253
269
  sortCol = SORT_COL_MAP[opts.sort_by];
@@ -280,7 +296,7 @@ function queryItemsFn(db, opts) {
280
296
  i.confidence, i.reinforcement_count, i.last_reinforced,
281
297
  i.created, i.last_touched, i.subtype,
282
298
  i.description, i.resource, i.brief, i.session_log_count, i.last_session_date,
283
- i.last_session_type, i.last_session_summary
299
+ i.last_session_type, i.last_session_summary, i.session_date
284
300
  FROM items i ${where}
285
301
  ORDER BY ${sortCol} ${sortOrder}${limitClause}`;
286
302
  const rows = db.prepare(sql).all(params);
@@ -310,9 +326,13 @@ function searchFn(db, query, kind) {
310
326
  const { fts: ftsQuery, terms } = sanitizeFtsQuery(query);
311
327
  if (!ftsQuery)
312
328
  return [];
313
- let kindFilter = '';
329
+ // Same rule as queryItemsFn: unset is the working set, `all` is literal.
330
+ let kindFilter = "AND i.kind != 'session'";
314
331
  const params = { query: ftsQuery };
315
- if (kind && kind !== 'all') {
332
+ if (kind === 'all') {
333
+ kindFilter = '';
334
+ }
335
+ else if (kind) {
316
336
  kindFilter = 'AND i.kind = @kind';
317
337
  params.kind = kind;
318
338
  }
@@ -553,11 +573,13 @@ export function createIndex(vaultPath, opts) {
553
573
  INSERT INTO items
554
574
  (slug, kind, name, type, status, parent, next_action, next_action_count, staleness_days,
555
575
  confidence, reinforcement_count, last_reinforced, created, last_touched, body, file_hash, subtype,
556
- description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary)
576
+ description, resource, brief, session_log_count, last_session_date, last_session_type, last_session_summary,
577
+ session_date)
557
578
  VALUES
558
579
  (@slug, @kind, @name, @type, @status, @parent, @next_action, @next_action_count, @staleness_days,
559
580
  @confidence, @reinforcement_count, @last_reinforced, @created, @last_touched, @body, @file_hash, @subtype,
560
- @description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary)
581
+ @description, @resource, @brief, @session_log_count, @last_session_date, @last_session_type, @last_session_summary,
582
+ @session_date)
561
583
  `);
562
584
  const deleteItemStmt = db.prepare('DELETE FROM items WHERE slug = ?');
563
585
  const deleteLinksStmt = db.prepare('DELETE FROM links WHERE source = ?');
@@ -620,6 +642,8 @@ export function createIndex(vaultPath, opts) {
620
642
  last_session_date: lastLog?.date ?? null,
621
643
  last_session_type: lastLog?.type ?? null,
622
644
  last_session_summary: lastLog?.summary?.slice(0, 300) ?? null,
645
+ // A session note's one day; the created stamp when digest gave none.
646
+ session_date: kind === 'session' ? (fm['session-date'] ?? fm.created) : null,
623
647
  };
624
648
  // Replace item and rebuild links. Delete-then-insert keeps the FTS index
625
649
  // trigger-consistent for externally changed files (hand edit, git pull),
@@ -657,12 +681,12 @@ export function createIndex(vaultPath, opts) {
657
681
  const linkRe = new RegExp(WIKI_LINK_RE.source, 'g');
658
682
  while ((wikiMatch = linkRe.exec(authoredBody)) !== null) {
659
683
  const targetSlug = wikiMatch[1];
660
- // Check if target file exists on disk (not in index — target may not be indexed yet)
661
- const targetEntityPath = join(vaultPath, kindDir('entity'), `${targetSlug}.md`);
662
- const targetKnowledgePath = join(vaultPath, kindDir('knowledge'), `${targetSlug}.md`);
684
+ // Check if target file exists on disk (not in index — target may not be
685
+ // indexed yet), in every indexed dir — the same set the rebuild's
686
+ // knownSlugs covers, so a mention links the same way on either path.
663
687
  const targetExists = knownSlugs
664
688
  ? knownSlugs.has(targetSlug)
665
- : existsSync(targetEntityPath) || existsSync(targetKnowledgePath);
689
+ : INDEXED_KINDS.some(k => existsSync(join(vaultPath, kindDir(k), `${targetSlug}.md`)));
666
690
  if (targetExists) {
667
691
  insertLinkStmt.run(slug, targetSlug, 'mention');
668
692
  }
@@ -675,17 +699,15 @@ export function createIndex(vaultPath, opts) {
675
699
  db.exec('DELETE FROM links');
676
700
  // Collect all vault slugs for wiki link target validation
677
701
  const allVaultSlugs = new Set();
678
- for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
679
- allVaultSlugs.add(basename(file, '.md'));
680
- }
681
- for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
682
- allVaultSlugs.add(basename(file, '.md'));
683
- }
684
- for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
685
- indexFile(file, 'entity', allVaultSlugs);
702
+ for (const kind of INDEXED_KINDS) {
703
+ for (const file of scanVaultDir(join(vaultPath, kindDir(kind)))) {
704
+ allVaultSlugs.add(basename(file, '.md'));
705
+ }
686
706
  }
687
- for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
688
- indexFile(file, 'knowledge', allVaultSlugs);
707
+ for (const kind of INDEXED_KINDS) {
708
+ for (const file of scanVaultDir(join(vaultPath, kindDir(kind)))) {
709
+ indexFile(file, kind, allVaultSlugs);
710
+ }
689
711
  }
690
712
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
691
713
  .run(scanStart.toString());
@@ -772,6 +794,7 @@ function withReadSurface(core) {
772
794
  queryChildren: (parentSlug) => queryEntitiesFn(index, { parent: parentSlug }),
773
795
  queryRelatedTo: (slug, relation) => queryRelatedToFn(index, slug, relation),
774
796
  queryKnowledge: (opts) => queryKnowledgeFn(index, opts),
797
+ querySessions: (opts) => querySessionsFn(index, opts),
775
798
  queryUnified: (opts) => queryUnifiedFn(index, opts),
776
799
  };
777
800
  return index;
@@ -812,6 +835,23 @@ function toKnowledgeResult(slug, row) {
812
835
  last_touched: row.last_touched ?? null,
813
836
  };
814
837
  }
838
+ function toSessionResult(slug, row) {
839
+ return {
840
+ slug,
841
+ name: row.name,
842
+ session_date: row.session_date,
843
+ created: row.created,
844
+ related: row.related ?? [],
845
+ };
846
+ }
847
+ /** One row of any kind, shaped for its kind. */
848
+ function toUnifiedResult(i) {
849
+ if (i.kind === 'entity')
850
+ return { ...toQueryResult(i.slug, i), kind: 'entity' };
851
+ if (i.kind === 'session')
852
+ return { ...toSessionResult(i.slug, i), kind: 'session' };
853
+ return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
854
+ }
815
855
  const STATUS_FILTERS = new Set(ENTITY_STATUSES);
816
856
  function mapEntityFilterOpts(qopts, opts) {
817
857
  if (opts?.filter && STATUS_FILTERS.has(opts.filter))
@@ -867,11 +907,16 @@ function validateEntityFilterOpts(opts) {
867
907
  * Asserted against the CLI's flag matrix in query-surface.test.ts. */
868
908
  export const ENTITY_ONLY_OPTS = ['filter', 'type', 'parent'];
869
909
  export const KNOWLEDGE_ONLY_OPTS = ['confidence', 'subtype'];
910
+ /** What a session query cannot map: both sides' opts, and the two every
911
+ * other kind shares — a session note carries no domain and no tags. */
912
+ export const SESSION_REJECTED_OPTS = [...ENTITY_ONLY_OPTS, ...KNOWLEDGE_ONLY_OPTS, 'domain', 'tags'];
870
913
  /** The one sentence both scope rejections speak — opts here, sort fields in
871
914
  * queryItemsFn — so a caller sees the same shape whichever one they hit. */
872
915
  function outOfScope(label, name, branch) {
873
- const belongsTo = branch === 'entity' ? 'knowledge notes only' : 'entities only';
874
- throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query both.`);
916
+ const belongsTo = branch === 'entity' ? 'knowledge notes only'
917
+ : branch === 'knowledge' ? 'entities only'
918
+ : 'entities and knowledge notes only';
919
+ throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query every kind.`);
875
920
  }
876
921
  function rejectUnmappableOpts(opts, reject, branch) {
877
922
  if (!opts)
@@ -901,15 +946,11 @@ function queryEntitiesFn(index, opts) {
901
946
  const { items, total } = index.queryItems(qopts);
902
947
  return { items: items.map(i => toQueryResult(i.slug, i)), total };
903
948
  }
949
+ /** Every kind: the sessions that touched an entity are the look back from it. */
904
950
  function queryRelatedToFn(index, slug, relation) {
905
951
  index.syncIfStale();
906
- const { items, total } = index.queryItems({ relatedTo: slug, relation });
907
- return {
908
- items: items.map(i => i.kind === 'entity'
909
- ? { ...toQueryResult(i.slug, i), kind: 'entity' }
910
- : { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' }),
911
- total,
912
- };
952
+ const { items, total } = index.queryItems({ kind: 'all', relatedTo: slug, relation });
953
+ return { items: items.map(toUnifiedResult), total };
913
954
  }
914
955
  function queryKnowledgeFn(index, opts) {
915
956
  index.syncIfStale();
@@ -924,6 +965,17 @@ function queryKnowledgeFn(index, opts) {
924
965
  const { items, total } = index.queryItems(qopts);
925
966
  return { items: items.map(i => toKnowledgeResult(i.slug, i)), total };
926
967
  }
968
+ function querySessionsFn(index, opts) {
969
+ index.syncIfStale();
970
+ rejectUnmappableOpts(opts, SESSION_REJECTED_OPTS, 'session');
971
+ validateTimeOpts(opts);
972
+ validateListOpts(opts);
973
+ const qopts = { kind: 'session' };
974
+ mapTimeOpts(qopts, opts);
975
+ mapListOpts(qopts, opts);
976
+ const { items, total } = index.queryItems(qopts);
977
+ return { items: items.map(i => toSessionResult(i.slug, i)), total };
978
+ }
927
979
  function queryUnifiedFn(index, opts) {
928
980
  index.syncIfStale();
929
981
  // The public type is a union discriminated on `kind`, so an out-of-scope opt
@@ -931,7 +983,7 @@ function queryUnifiedFn(index, opts) {
931
983
  // an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
932
984
  // once, here, and let the checks below speak for both.
933
985
  const o = opts;
934
- assertEnum(o?.kind, ['entity', 'knowledge', 'all'], 'kind');
986
+ assertEnum(o?.kind, ['entity', 'knowledge', 'session', 'all'], 'kind');
935
987
  validateEntityFilterOpts(o);
936
988
  validateKnowledgeFilterOpts(o);
937
989
  // Only `kind: 'all'` (or unset) maps both sides. `kind` also narrows this
@@ -943,24 +995,16 @@ function queryUnifiedFn(index, opts) {
943
995
  rejectUnmappableOpts(o, KNOWLEDGE_ONLY_OPTS, 'entity');
944
996
  if (o?.kind === 'knowledge')
945
997
  rejectUnmappableOpts(o, ENTITY_ONLY_OPTS, 'knowledge');
998
+ if (o?.kind === 'session')
999
+ rejectUnmappableOpts(o, SESSION_REJECTED_OPTS, 'session');
946
1000
  validateTimeOpts(o);
947
1001
  validateListOpts(o);
948
- const qopts = {};
949
- if (o?.kind && o.kind !== 'all')
950
- qopts.kind = o.kind;
951
- // When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
1002
+ // Unset is the working set (entity + knowledge); 'all' reaches every kind.
1003
+ const qopts = { kind: o?.kind };
952
1004
  mapEntityFilterOpts(qopts, o);
953
1005
  mapKnowledgeFilterOpts(qopts, o);
954
1006
  mapTimeOpts(qopts, o);
955
1007
  mapListOpts(qopts, o);
956
1008
  const result = index.queryItems(qopts);
957
- const items = result.items.map(i => {
958
- if (i.kind === 'entity') {
959
- return { ...toQueryResult(i.slug, i), kind: 'entity' };
960
- }
961
- else {
962
- return { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' };
963
- }
964
- });
965
- return { items, total: result.total };
1009
+ return { items: result.items.map(toUnifiedResult), total: result.total };
966
1010
  }
package/dist/git.js CHANGED
@@ -6,6 +6,49 @@ import { mergeVaultContent } from './merge.js';
6
6
  import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath, vaultKindFromRelPath } from './vault.js';
7
7
  import { normalizeSinceDate } from './dates.js';
8
8
  import { HafezError } from './types.js';
9
+ // Hafez runs git in the user's own shell, and git inherited that environment before simple-git
10
+ // guarded it. simple-git 4 guards it two ways, and both are opened in full: `allowEnvironment`
11
+ // names every key the process carries (the guard otherwise strips ambient GIT_* / EDITOR / PAGER
12
+ // keys and rejects supplied ones — a user's GIT_SSH_COMMAND or GIT_AUTHOR_* would vanish), and
13
+ // `unsafe` opens every category — `Required`, so a category the library adds fails the build
14
+ // instead of rejecting some user's shell at runtime (4.0 moved GIT_EXEC_PATH to allowUnsafeExec).
15
+ const OPEN_UNSAFE = {
16
+ allowUnsafeAlias: true,
17
+ allowUnsafeAskPass: true,
18
+ allowUnsafeCommandBinaries: true,
19
+ allowUnsafeConfigEnvCount: true,
20
+ allowUnsafeConfigPaths: true,
21
+ allowUnsafeCredentialHelper: true,
22
+ allowUnsafeDiffExternal: true,
23
+ allowUnsafeDiffTextConv: true,
24
+ allowUnsafeEditor: true,
25
+ allowUnsafeExec: true,
26
+ allowUnsafeFilter: true,
27
+ allowUnsafeFsMonitor: true,
28
+ allowUnsafeGitProxy: true,
29
+ allowUnsafeGpgProgram: true,
30
+ allowUnsafeHooksPath: true,
31
+ allowUnsafeInclude: true,
32
+ allowUnsafeMergeDriver: true,
33
+ allowUnsafePack: true,
34
+ allowUnsafePager: true,
35
+ allowUnsafeProtocolOverride: true,
36
+ allowUnsafeSshCommand: true,
37
+ allowUnsafeSubmodule: true,
38
+ allowUnsafeTemplateDir: true,
39
+ allowUnsafeUrlRewrite: true,
40
+ allowUnsafeCustomBinary: true,
41
+ // Otherwise the plugin exports GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS to the child and the
42
+ // vault's own hooks start failing on abbreviated long options only when hafez commits.
43
+ allowAbbreviatedOptions: true
44
+ };
45
+ // Hafez's own git calls are never interactive: GIT_EDITOR outranks core.editor, -c, VISUAL and
46
+ // EDITOR. `.env()` replaces the child env, hence the spread.
47
+ const vaultGit = (vaultPath) => simpleGit({
48
+ baseDir: vaultPath,
49
+ allowEnvironment: [...Object.keys(process.env), 'GIT_EDITOR'],
50
+ unsafe: OPEN_UNSAFE
51
+ }).env({ ...process.env, GIT_EDITOR: 'true' });
9
52
  const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
10
53
  const MAX_PUSH_RETRIES = 3;
11
54
  const RETRY_BASE_MS = 200;
@@ -279,7 +322,7 @@ async function pushResolving(git, vaultPath, conflictMessage, exhaustedMessage)
279
322
  * All work must be committed before calling — no file save/restore needed.
280
323
  */
281
324
  export async function gitSync(vaultPath) {
282
- const git = simpleGit(vaultPath);
325
+ const git = vaultGit(vaultPath);
283
326
  if (!(await hasOriginRemote(git))) {
284
327
  return { pulled: false, pushed: false, remote: false };
285
328
  }
@@ -330,7 +373,7 @@ export async function gitSync(vaultPath) {
330
373
  */
331
374
  async function gitUnstage(vaultPath, files) {
332
375
  try {
333
- await simpleGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
376
+ await vaultGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
334
377
  }
335
378
  catch { /* best effort — rollback must not mask the original error */ }
336
379
  }
@@ -390,8 +433,26 @@ function parseNameStatusLine(line) {
390
433
  const operation = FILE_STATUS_MAP[match[1]];
391
434
  return operation ? [{ path: match[2], operation }] : [];
392
435
  }
436
+ /**
437
+ * Read-side "no history": the one state where `git log` has nothing to say
438
+ * and empty is the true answer — a repo with no commits yet. Everything else
439
+ * (a directory that is not a repository, an env-guard or unsafe-category
440
+ * rejection, a missing git binary, a corrupt repo) is a failure the write
441
+ * side already reports loudly, so the read side must not hide it. Matched on
442
+ * git's stderr text, which simple-git carries in `GitError.message`; both
443
+ * callers pass a `--` pathspec and no revision, and an unborn HEAD then says
444
+ * exactly this.
445
+ */
446
+ const NO_HISTORY_RE = /does not have any commits yet/i;
447
+ function isNoHistory(err) {
448
+ return NO_HISTORY_RE.test(err instanceof Error ? err.message : String(err));
449
+ }
450
+ function gitReadFailed(err) {
451
+ const lines = (err instanceof Error ? err.message : String(err)).split('\n').map(l => l.trim()).filter(Boolean);
452
+ return new HafezError('GIT_READ_FAILED', `git read failed: ${lines[0] ?? 'unknown error'}`, lines);
453
+ }
393
454
  export async function gitChangelog(vaultPath, since) {
394
- const git = simpleGit(vaultPath);
455
+ const git = vaultGit(vaultPath);
395
456
  // Bare dates become local midnight — the same calendar-day boundary the
396
457
  // vault stamps and query comparisons use (see dates.ts).
397
458
  const normalizedSince = normalizeSinceDate(since);
@@ -402,8 +463,10 @@ export async function gitChangelog(vaultPath, since) {
402
463
  '--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
403
464
  ]);
404
465
  }
405
- catch {
406
- return []; // No git history or invalid since — return empty
466
+ catch (err) {
467
+ if (isNoHistory(err))
468
+ return [];
469
+ throw gitReadFailed(err);
407
470
  }
408
471
  if (!output.trim())
409
472
  return [];
@@ -439,7 +502,7 @@ export async function gitChangelog(vaultPath, since) {
439
502
  * Files with no history (not yet committed) are absent from the map.
440
503
  */
441
504
  export async function gitFileTimes(vaultPath, mode) {
442
- const git = simpleGit(vaultPath);
505
+ const git = vaultGit(vaultPath);
443
506
  let output;
444
507
  try {
445
508
  const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', ...INDEXED_DIR_PATHSPECS];
@@ -447,8 +510,10 @@ export async function gitFileTimes(vaultPath, mode) {
447
510
  args.splice(1, 0, '--diff-filter=A');
448
511
  output = await git.raw(args);
449
512
  }
450
- catch {
451
- return new Map(); // no git history
513
+ catch (err) {
514
+ if (isNoHistory(err))
515
+ return new Map();
516
+ throw gitReadFailed(err);
452
517
  }
453
518
  const times = new Map();
454
519
  let current = '';
@@ -464,7 +529,7 @@ export async function gitFileTimes(vaultPath, mode) {
464
529
  return times;
465
530
  }
466
531
  export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
467
- const git = simpleGit(vaultPath);
532
+ const git = vaultGit(vaultPath);
468
533
  // If a previous run crashed mid-rebase/merge, the repo is stuck.
469
534
  // Aborting restores tracked files to their pre-rebase state, which may
470
535
  // overwrite files that index.ts just wrote. Save and restore them.
package/dist/index.d.ts CHANGED
@@ -1,6 +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, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, 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';
4
+ export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, SessionQueryOpts, SessionQueryResult, SearchKind, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, KnowledgeSortField, SessionSortField, 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
5
  export { getBrief, getNextActions } from './document.js';
6
6
  export { parseSessionLogHeading, parseSessionLog } from './document.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { parseFilePath, slugify, kindFromPath, kindDir, isIndexedRelPath, findVaultFile, vaultFileExists, resolveFilePath, scanVaultDir, INDEXED_KINDS } from './vault.js';
2
+ import { parseFilePath, slugify, kindFromPath, kindDir, isMutableRelPath, findVaultFile, findReadableFile, vaultFileExists, resolveFilePath, scanVaultDir, archiveLogPath, MUTABLE_KINDS } from './vault.js';
3
3
  import { createGitJournal } from './git.js';
4
4
  import { validateSessionLogEntry } from './schema.js';
5
5
  import { validateVault } from './validate.js';
@@ -52,6 +52,7 @@ export function createHafez(config) {
52
52
  }
53
53
  }
54
54
  // Layout answers where a slug lives; these bind the vault path once.
55
+ // Mutations resolve the mutable kinds; `read` reaches a session note too.
55
56
  function findFile(slug) { return findVaultFile(vaultPath, slug); }
56
57
  function exists(slug) { return vaultFileExists(vaultPath, slug); }
57
58
  function regenerateIndexSafe() {
@@ -92,7 +93,7 @@ export function createHafez(config) {
92
93
  return idx ? fn(idx) : empty;
93
94
  }
94
95
  async function read(slug, depth = 'summary') {
95
- const filePath = findFile(slug);
96
+ const filePath = findReadableFile(vaultPath, slug);
96
97
  if (!filePath) {
97
98
  // A miss carries the closest slugs as its details: the recovery the
98
99
  // `read` miss output renders (#91).
@@ -100,9 +101,6 @@ export function createHafez(config) {
100
101
  throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
101
102
  }
102
103
  const parsed = parseFilePath(filePath);
103
- // findFile only resolves the indexed kinds, so the session arm is unreachable
104
- if (parsed.kind === 'session')
105
- throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
106
104
  if (depth === 'frontmatter')
107
105
  return { ...parsed, body: '' };
108
106
  if (depth === 'summary') {
@@ -110,6 +108,15 @@ export function createHafez(config) {
110
108
  }
111
109
  return parsed;
112
110
  }
111
+ /** The archive is an attachment of its entity, reached from it on purpose
112
+ * (#34): a disk read behind the entity's own slug, never an index row. */
113
+ async function archive(slug) {
114
+ const entity = resolveFilePath(vaultPath, slug, 'entity');
115
+ if (!existsSync(entity))
116
+ throw new HafezError('NOT_FOUND', `Entity '${slug}' not found`);
117
+ const path = archiveLogPath(vaultPath, slug);
118
+ return existsSync(path) ? readFileSync(path, 'utf-8') : null;
119
+ }
113
120
  function rollbackFiles(originals) {
114
121
  for (const [path, content] of originals) {
115
122
  if (content === null) {
@@ -216,12 +223,10 @@ export function createHafez(config) {
216
223
  const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
217
224
  const finalize = () => {
218
225
  updateIndexPostCommit(applied);
219
- // index.md regeneration is gated: session-only writes skip it
220
- if ([...writtenAll, ...deletedAll].some(isIndexedRelPath)) {
226
+ // index.md shows the mutable kinds: a session-only write leaves it.
227
+ if ([...writtenAll, ...deletedAll].some(isMutableRelPath)) {
221
228
  regenerateIndexSafe();
222
229
  }
223
- // Session creates carry no index upserts — they are not indexed
224
- // entities and never notified.
225
230
  for (const a of applied) {
226
231
  if (a.indexUpserts.length > 0)
227
232
  onWrite?.(a.result.slug, single ? single.action : 'batch');
@@ -390,9 +395,11 @@ export function createHafez(config) {
390
395
  simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
391
396
  // Read from disk, not the index: the validate phase must see the vault
392
397
  // as `validate` will, and a stale index row would let a linked note go.
398
+ // The mutable kinds only: a session note's related are a record of
399
+ // what it touched, and a record must not keep a note from being deleted.
393
400
  referrers: (slug) => {
394
401
  const candidates = new Set(simulated.keys());
395
- for (const kind of INDEXED_KINDS) {
402
+ for (const kind of MUTABLE_KINDS) {
396
403
  for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
397
404
  candidates.add(basename(fp, '.md'));
398
405
  }
@@ -518,6 +525,8 @@ export function createHafez(config) {
518
525
  children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
519
526
  related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
520
527
  query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
528
+ query_sessions: async (opts) => withIndex(idx => idx.querySessions(opts), { items: [], total: 0 }),
529
+ archive,
521
530
  validate,
522
531
  search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
523
532
  rebuildIndex: async () => {
@@ -540,6 +549,17 @@ export function createHafez(config) {
540
549
  return s;
541
550
  },
542
551
  changelog: (since) => journal.changelog(since),
552
+ close: () => {
553
+ // Forget the handle even if closing it throws: a retry must not
554
+ // re-throw, and getIndex() must never hand out a closed handle.
555
+ try {
556
+ _index?.close();
557
+ }
558
+ finally {
559
+ _index = null;
560
+ _indexInitialized = false;
561
+ }
562
+ },
543
563
  };
544
564
  }
545
565
  export { HafezError } from './types.js';
@@ -5,7 +5,7 @@
5
5
  * imports from this file. */
6
6
  export interface VaultIndexRow {
7
7
  slug: string;
8
- kind: 'entity' | 'knowledge';
8
+ kind: 'entity' | 'knowledge' | 'session';
9
9
  name: string;
10
10
  type: string | null;
11
11
  status: string | null;
package/dist/schema.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export declare function validateEntityFrontmatter(fm: Record<string, any>): string[];
2
2
  export declare function validateKnowledgeFrontmatter(fm: Record<string, any>): string[];
3
+ /** A session note: its name and created stamp, nothing else is required. */
4
+ export declare function validateSessionFrontmatter(fm: Record<string, any>): string[];
3
5
  export declare function validateKnowledgeSubtype(fm: Record<string, any>): string[];
4
6
  export declare function validateSlugReference(slug: string, field: string, exists: (s: string) => boolean): string | null;
5
7
  export declare function validateSessionLogEntry(entry: {
package/dist/schema.js CHANGED
@@ -30,6 +30,17 @@ export function validateKnowledgeFrontmatter(fm) {
30
30
  errors.push(...validateKnowledgeSubtype(fm));
31
31
  return errors;
32
32
  }
33
+ /** A session note: its name and created stamp, nothing else is required. */
34
+ export function validateSessionFrontmatter(fm) {
35
+ const errors = [];
36
+ if (!fm.name || typeof fm.name !== 'string')
37
+ errors.push('missing required field: name');
38
+ if (!fm.created)
39
+ errors.push('missing required field: created');
40
+ if (fm.related !== undefined && !Array.isArray(fm.related))
41
+ errors.push('session related must be an array');
42
+ return errors;
43
+ }
33
44
  export function validateKnowledgeSubtype(fm) {
34
45
  const errors = [];
35
46
  if (fm.subtype && !VALID_SUBTYPES.includes(fm.subtype)) {