hafez 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,25 @@
1
+ /** One indexed document, as the SQLite index can hand it over. index.md needs
2
+ * nothing the index does not already hold, so a regeneration reads rows
3
+ * instead of re-reading and re-parsing every file in the vault. Declared here
4
+ * (not in db.ts) to keep the module edge pointing one way: db.ts already
5
+ * imports from this file. */
6
+ export interface VaultIndexRow {
7
+ slug: string;
8
+ kind: 'entity' | 'knowledge';
9
+ name: string;
10
+ type: string | null;
11
+ status: string | null;
12
+ confidence: string | null;
13
+ description: string | null;
14
+ domain: string[];
15
+ body: string;
16
+ }
1
17
  /** Ensure every generated local-only artifact is covered by .git/info/exclude. */
2
18
  export declare function ensureLocalExclude(vaultPath: string): void;
3
- export declare function generateVaultIndex(vaultPath: string): void;
19
+ /** Regenerate the vault-root index.md.
20
+ *
21
+ * `rows` is the SQLite index's view of the vault — every mutation refreshes
22
+ * those rows anyway, so passing them avoids a second full-vault read for the
23
+ * same content. Omitting them (standalone/one-off use) falls back to reading
24
+ * the files directly. */
25
+ export declare function generateVaultIndex(vaultPath: string, rows?: VaultIndexRow[]): void;
@@ -2,10 +2,28 @@
2
2
  // Generates the vault-root index.md covering entities (grouped by status) and
3
3
  // knowledge (grouped by domain) — local-only, auto-regenerated on every entity/knowledge write.
4
4
  import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
5
- import { join, basename } from 'path';
5
+ import { join, basename, resolve } from 'path';
6
6
  import { parseFilePath, scanVaultDir, kindDir } from './vault.js';
7
7
  import { deriveSummary } from './document.js';
8
8
  import { ENTITY_STATUSES } from './contracts.js';
9
+ /** Index rows in the shape the renderer below expects — the same
10
+ * {slug, fm, body} triple scanParsed produces from disk. */
11
+ function fromRows(rows, kind) {
12
+ return rows
13
+ .filter(r => r.kind === kind)
14
+ .map(r => ({
15
+ slug: r.slug,
16
+ body: r.body,
17
+ fm: {
18
+ name: r.name,
19
+ type: r.type ?? undefined,
20
+ status: r.status ?? undefined,
21
+ confidence: r.confidence ?? undefined,
22
+ description: r.description ?? undefined,
23
+ domain: r.domain,
24
+ },
25
+ }));
26
+ }
9
27
  function truncate(s) {
10
28
  return s.length > 100 ? s.slice(0, 97) + '...' : s;
11
29
  }
@@ -38,11 +56,42 @@ function scanParsed(dir) {
38
56
  * show up in the user's `git status`, but forcing a tracked .gitignore entry
39
57
  * would dirty their vault — so they go into .git/info/exclude instead. */
40
58
  const LOCAL_EXCLUDES = ['/index.md', '/.hafez.db', '/.hafez.db-shm', '/.hafez.db-wal'];
59
+ /** Locate the git directory that owns `info/exclude` for this vault.
60
+ * `.git` is a directory in a normal clone, but a *gitfile* (`gitdir: <path>`)
61
+ * in a linked worktree or submodule — treating that file as "not a repo" is
62
+ * what let index.md and .hafez.db pollute `git status` there. Git reads
63
+ * `info/exclude` from the COMMON dir, so for a linked worktree the pointed-at
64
+ * per-worktree dir is one more hop from the answer: its `commondir` file names
65
+ * the shared dir (writing the exclude in the per-worktree dir has no effect).
66
+ * Returns null when the vault is not a git repo at all. */
67
+ function resolveExcludeGitDir(vaultPath) {
68
+ const dotGit = join(vaultPath, '.git');
69
+ if (!existsSync(dotGit))
70
+ return null;
71
+ const stat = statSync(dotGit);
72
+ if (stat.isDirectory())
73
+ return dotGit;
74
+ if (!stat.isFile())
75
+ return null;
76
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf-8'));
77
+ if (!pointer)
78
+ return null;
79
+ const gitDir = resolve(vaultPath, pointer[1].trim());
80
+ if (!existsSync(gitDir))
81
+ return null;
82
+ const commonDirFile = join(gitDir, 'commondir');
83
+ if (existsSync(commonDirFile)) {
84
+ const commonDir = resolve(gitDir, readFileSync(commonDirFile, 'utf-8').trim());
85
+ if (existsSync(commonDir))
86
+ return commonDir;
87
+ }
88
+ return gitDir;
89
+ }
41
90
  /** Ensure every generated local-only artifact is covered by .git/info/exclude. */
42
91
  export function ensureLocalExclude(vaultPath) {
43
92
  try {
44
- const gitDir = join(vaultPath, '.git');
45
- if (!existsSync(gitDir) || !statSync(gitDir).isDirectory())
93
+ const gitDir = resolveExcludeGitDir(vaultPath);
94
+ if (!gitDir)
46
95
  return;
47
96
  const excludePath = join(gitDir, 'info', 'exclude');
48
97
  const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
@@ -56,17 +105,24 @@ export function ensureLocalExclude(vaultPath) {
56
105
  }
57
106
  catch { /* best effort — never fail a vault write over ignore hygiene */ }
58
107
  }
59
- export function generateVaultIndex(vaultPath) {
108
+ /** Regenerate the vault-root index.md.
109
+ *
110
+ * `rows` is the SQLite index's view of the vault — every mutation refreshes
111
+ * those rows anyway, so passing them avoids a second full-vault read for the
112
+ * same content. Omitting them (standalone/one-off use) falls back to reading
113
+ * the files directly. */
114
+ export function generateVaultIndex(vaultPath, rows) {
60
115
  const entitiesDir = join(vaultPath, kindDir('entity'));
61
116
  const knowledgeDir = join(vaultPath, kindDir('knowledge'));
62
117
  const hasEntities = existsSync(entitiesDir);
63
118
  const hasKnowledge = existsSync(knowledgeDir);
64
119
  if (!hasEntities && !hasKnowledge)
65
120
  return;
121
+ const docsFor = (kind, dir) => rows ? fromRows(rows, kind) : scanParsed(dir);
66
122
  const lines = ['# Vault Index', '', '> Auto-generated by hafez. Do not edit manually.', ''];
67
123
  if (hasEntities) {
68
124
  const byStatus = new Map();
69
- for (const { slug, fm, body } of scanParsed(entitiesDir)) {
125
+ for (const { slug, fm, body } of docsFor('entity', entitiesDir)) {
70
126
  const status = fm.status ?? 'active';
71
127
  const list = byStatus.get(status) ?? [];
72
128
  list.push({ slug, name: fm.name, summary: truncate(deriveSummary('entity', fm, body)), suffix: fm.type ?? 'entity' });
@@ -92,7 +148,7 @@ export function generateVaultIndex(vaultPath) {
92
148
  }
93
149
  if (hasKnowledge) {
94
150
  const entries = [];
95
- for (const { slug, fm, body } of scanParsed(knowledgeDir)) {
151
+ for (const { slug, fm, body } of docsFor('knowledge', knowledgeDir)) {
96
152
  entries.push({
97
153
  slug,
98
154
  name: fm.name,
package/dist/merge.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function mergeVaultContent(remote: string, local: string): string;
1
+ export declare function mergeVaultContent(remote: string, local: string, kind: 'entity' | 'knowledge' | 'session'): string;
package/dist/merge.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // src/merge.ts
2
- import { parseContent, serializeFile } from './vault.js';
3
- import { parseSessionLogHeading, splitStructural } from './document.js';
2
+ import { parseContent, serializeFile, ARRAY_FIELDS } from './vault.js';
3
+ import { parseSessionLogHeading, splitLogEntries, documentFor } from './document.js';
4
4
  import { APPEND_ONLY_SECTIONS } from './contracts.js';
5
5
  // Fields that are ISO date strings — latest date wins
6
6
  const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
7
7
  // Fields that are numeric counters — max wins
8
8
  const COUNT_FIELDS = ['reinforcement-count', 'staleness-days'];
9
- // Fields that are always arrays — union with dedup
10
- const ARRAY_FIELDS_UNCAPPED = ['tags', 'domain', 'related'];
11
- export function mergeVaultContent(remote, local) {
9
+ // Array fields (vault.ARRAY_FIELDS — the same set parsing coerces) union with dedup
10
+ export function mergeVaultContent(remote, local, kind) {
12
11
  const r = parseContent(remote);
13
12
  const l = parseContent(local);
14
13
  // Scalar policy: the side with the newer last-touched wins; local on tie.
@@ -16,7 +15,18 @@ export function mergeVaultContent(remote, local) {
16
15
  // array/date/count frontmatter fields are always unioned regardless.
17
16
  const localWins = String(l.frontmatter?.['last-touched'] ?? '') >= String(r.frontmatter?.['last-touched'] ?? '');
18
17
  const fm = mergeFrontmatter(r.frontmatter, l.frontmatter, localWins);
19
- const body = mergeBody(r.body, l.body, localWins);
18
+ // The Document binds by kind — the same binding every write path uses (see
19
+ // DocContext), so a section this merge preserves stays structural for the
20
+ // next update too. The two sides come from divergent histories whose type
21
+ // may differ (a promote race leaves one side 'capture' and the other
22
+ // 'project'); the kind-level set keeps every same-kind section structural,
23
+ // while cross-KIND headings (a session-only ## Decisions quoted in an
24
+ // entity Brief) are not boundaries. Known residual: a same-kind heading
25
+ // quoted at line start in one side's section splits at that false boundary
26
+ // and can survive the merge as its own section — the fix is fence/quote
27
+ // awareness in Document's splitter, not a narrower binding here.
28
+ const doc = documentFor({ kind });
29
+ const body = mergeBody(doc, r.body, l.body, localWins);
20
30
  return serializeFile(fm, body);
21
31
  }
22
32
  function mergeFrontmatter(remote, local, localWins) {
@@ -39,7 +49,7 @@ function mergeFrontmatter(remote, local, localWins) {
39
49
  merged[key] = r;
40
50
  }
41
51
  // Array fields: union
42
- for (const key of ARRAY_FIELDS_UNCAPPED) {
52
+ for (const key of ARRAY_FIELDS) {
43
53
  merged[key] = unionArrays(remote[key], local[key]);
44
54
  if (merged[key].length === 0)
45
55
  delete merged[key];
@@ -47,20 +57,21 @@ function mergeFrontmatter(remote, local, localWins) {
47
57
  return merged;
48
58
  }
49
59
  function unionArrays(a, b) {
50
- // Local (b) items first so they survive cap truncation
60
+ // Local (b) items first: nothing is truncated, but the local side's order is
61
+ // the one the user last saw, so it stays the order they get back.
51
62
  const arrA = Array.isArray(a) ? a : a ? [a] : [];
52
63
  const arrB = Array.isArray(b) ? b : b ? [b] : [];
53
64
  return [...new Set([...arrB, ...arrA])];
54
65
  }
55
66
  // --- Body merge ---
56
- function mergeBody(remoteBody, localBody, localWins) {
67
+ function mergeBody(doc, remoteBody, localBody, localWins) {
57
68
  if (remoteBody === localBody)
58
69
  return localBody;
59
70
  // Structural split (see document.ts): sections are atomic — a user ##
60
71
  // heading inside a Brief or a session-log entry body stays with its
61
72
  // section instead of being split out and merged under its own policy.
62
- const remote = splitStructural(remoteBody);
63
- const local = splitStructural(localBody);
73
+ const remote = doc.splitStructural(remoteBody);
74
+ const local = doc.splitStructural(localBody);
64
75
  // Build merged sections starting from local (local section order is canonical)
65
76
  const localHeadings = new Set(local.sections.map(s => s.heading));
66
77
  const mergedSections = [];
@@ -90,10 +101,16 @@ function mergeBody(remoteBody, localBody, localWins) {
90
101
  mergedSections.push(rs);
91
102
  }
92
103
  }
93
- // Reassemble
94
- const preamble = localWins
95
- ? (local.preamble || remote.preamble)
96
- : (remote.preamble || local.preamble);
104
+ // Reassemble. A preamble that is itself a session-log entry stream is
105
+ // append-only content living outside any heading — the archive-log files
106
+ // under entities/archive/ are exactly this shape. Merging those by the
107
+ // scalar wins policy drops one machine's archived entries wholesale, so
108
+ // union them by the same entry policy the Session Log section uses.
109
+ const preamble = isLogEntryStream(remote.preamble) && isLogEntryStream(local.preamble)
110
+ ? mergeSessionLogs(remote.preamble, local.preamble)
111
+ : localWins
112
+ ? (local.preamble || remote.preamble)
113
+ : (remote.preamble || local.preamble);
97
114
  const parts = [preamble, ...mergedSections.map(s => `## ${s.heading}\n\n${s.content}`)].filter(Boolean);
98
115
  return parts.join('\n\n');
99
116
  }
@@ -111,6 +128,12 @@ function mergeAppendOnlyLines(remoteContent, localContent) {
111
128
  return localContent;
112
129
  return [...localLines, ...remoteOnly].join('\n');
113
130
  }
131
+ /** True when a block is nothing but session-log entries — it starts with an
132
+ * entry heading, so splitLogEntries drops no leading prose. */
133
+ function isLogEntryStream(text) {
134
+ const first = text.trim().split('\n')[0];
135
+ return first !== '' && parseSessionLogHeading(first) !== null;
136
+ }
114
137
  function mergeSessionLogs(remoteContent, localContent) {
115
138
  const remoteEntries = parseLogEntries(remoteContent);
116
139
  const localEntries = parseLogEntries(localContent);
@@ -128,64 +151,73 @@ function mergeSessionLogs(remoteContent, localContent) {
128
151
  return merged.map(e => e.raw).join('\n\n');
129
152
  }
130
153
  function parseLogEntries(content) {
131
- const entries = [];
132
- const blocks = content.split(/\n(?=### \d{4}-\d{2}-\d{2})/);
133
- for (const block of blocks) {
134
- const trimmed = block.trim();
135
- if (!trimmed)
136
- continue;
137
- const firstLine = trimmed.split('\n')[0];
138
- const parsed = parseSessionLogHeading(firstLine);
139
- if (parsed) {
140
- entries.push({
141
- key: `${parsed.date}|${parsed.agent}|${parsed.type}`,
142
- date: parsed.date,
143
- raw: trimmed,
144
- });
145
- }
146
- }
147
- return entries;
154
+ // Document owns entry block splitting; the identity key and dedup/sort
155
+ // policy stay here — merge semantics, not document structure.
156
+ //
157
+ // Identity is the entry's whole content. Date+agent+type is not an identity:
158
+ // two machines each running a digest on the same day produce two entries with
159
+ // the same heading and different bodies, and keying on the heading collapses
160
+ // them to one — the other machine's session history is gone. Entries the two
161
+ // sides share came from the same commit, so they are byte-identical and still
162
+ // dedup.
163
+ return splitLogEntries(content).map(raw => {
164
+ const parsed = parseSessionLogHeading(raw.split('\n')[0]);
165
+ return { key: raw, date: parsed.date, raw };
166
+ });
148
167
  }
149
168
  function mergeNextActions(remoteContent, localContent) {
150
- const remoteItems = parseActionItems(remoteContent);
151
- const localItems = parseActionItems(localContent);
152
- // Build merged set: start with local items
153
- const seen = new Set();
154
- const merged = [];
155
- // Add all local items
156
- for (const item of localItems) {
157
- seen.add(item.text);
158
- merged.push(item);
159
- }
160
- // Add remote items not in local
161
- for (const item of remoteItems) {
162
- if (!seen.has(item.text)) {
163
- seen.add(item.text);
164
- merged.push(item);
165
- }
166
- else {
167
- // If remote has it checked but local has it unchecked, upgrade to checked
168
- const existing = merged.find(m => m.text === item.text);
169
- if (existing && item.checked && !existing.checked) {
170
- existing.checked = true;
171
- }
169
+ const remote = parseActionItems(remoteContent);
170
+ const local = parseActionItems(localContent);
171
+ // Start with local items; index by action text for the remote pass
172
+ const merged = local.items.map(i => ({ ...i, notes: [...i.notes] }));
173
+ const byText = new Map(merged.map(i => [i.text, i]));
174
+ for (const item of remote.items) {
175
+ const existing = byText.get(item.text);
176
+ if (!existing) {
177
+ merged.push({ ...item, notes: [...item.notes] });
178
+ byText.set(item.text, merged[merged.length - 1]);
179
+ continue;
172
180
  }
181
+ // Checked on either side means done
182
+ if (item.checked)
183
+ existing.checked = true;
184
+ // Union the item's own notes/sub-bullets — they are authored content, not
185
+ // re-derivable from the checkbox line
186
+ existing.notes.push(...unionLines(item.notes, existing.notes));
187
+ }
188
+ const lines = [...local.preamble, ...unionLines(remote.preamble, local.preamble)];
189
+ for (const item of merged) {
190
+ lines.push(`- [${item.checked ? 'x' : ' '}] ${item.text}`);
191
+ lines.push(...item.notes);
173
192
  }
174
- return merged.map(i => `- [${i.checked ? 'x' : ' '}] ${i.text}`).join('\n');
193
+ return lines.join('\n').replace(/\n+$/, '');
175
194
  }
195
+ /** Lines of `from` not already present in `into` (blank lines only from `into`). */
196
+ function unionLines(from, into) {
197
+ const seen = new Set(into.map(l => l.trim()).filter(Boolean));
198
+ return from.filter(l => l.trim() && !seen.has(l.trim()));
199
+ }
200
+ /**
201
+ * Split a Next Actions section into its leading non-item lines and its items,
202
+ * each item carrying the lines that follow it (indented sub-bullets, notes)
203
+ * until the next checkbox. Non-checkbox lines are authored content — parsing
204
+ * checkbox lines alone and re-rendering from them deletes everything else.
205
+ * `- [X]` is the same as `- [x]`: a completed action, not an unknown line.
206
+ */
176
207
  function parseActionItems(content) {
208
+ const preamble = [];
177
209
  const items = [];
178
210
  for (const line of content.split('\n')) {
179
- const unchecked = line.match(/^- \[ \] (.+)$/);
180
- if (unchecked) {
181
- items.push({ checked: false, text: unchecked[1] });
182
- continue;
211
+ const match = line.match(/^- \[([ xX])\] (.+)$/);
212
+ if (match) {
213
+ items.push({ checked: match[1].toLowerCase() === 'x', text: match[2], notes: [] });
183
214
  }
184
- const checked = line.match(/^- \[x\] (.+)$/);
185
- if (checked) {
186
- items.push({ checked: true, text: checked[1] });
187
- continue;
215
+ else if (items.length === 0) {
216
+ preamble.push(line);
217
+ }
218
+ else {
219
+ items[items.length - 1].notes.push(line);
188
220
  }
189
221
  }
190
- return items;
222
+ return { preamble, items };
191
223
  }
package/dist/schema.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/schema.ts
2
- import { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, } from './contracts.js';
2
+ import { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES, } from './contracts.js';
3
3
  export function validateEntityFrontmatter(fm) {
4
4
  const errors = [];
5
5
  if (!fm.name || typeof fm.name !== 'string')
@@ -35,9 +35,9 @@ export function validateKnowledgeSubtype(fm) {
35
35
  if (fm.subtype && !VALID_SUBTYPES.includes(fm.subtype)) {
36
36
  errors.push(`invalid subtype: ${fm.subtype} (expected: ${VALID_SUBTYPES.join(', ')})`);
37
37
  }
38
- // Plan-specific: status must be active or done
39
- if (fm.subtype === 'plan' && fm.status && !['active', 'done'].includes(fm.status)) {
40
- errors.push(`invalid plan status: ${fm.status} (expected: active, done)`);
38
+ // Plan-specific: status must be one of the plan lifecycle statuses
39
+ if (fm.subtype === 'plan' && fm.status && !PLAN_STATUSES.includes(fm.status)) {
40
+ errors.push(`invalid plan status: ${fm.status} (expected: ${PLAN_STATUSES.join(', ')})`);
41
41
  }
42
42
  return errors;
43
43
  }
@@ -6,3 +6,9 @@ export declare function bodyTemplate(type: EntityType, fields?: {
6
6
  export declare function knowledgeBodyTemplate(subtype?: KnowledgeSubtype, fields?: {
7
7
  synthesis?: string;
8
8
  }): string;
9
+ /** Session bodies. Sessions have no TypeContract, but their headings are
10
+ * structural for Document splitting — so the list lives in contracts with
11
+ * every other section set, and this renders it. */
12
+ export declare function sessionBodyTemplate(fields?: {
13
+ synthesis?: string;
14
+ }): string;
package/dist/templates.js CHANGED
@@ -1,39 +1,39 @@
1
- import { SUBTYPE_SECTIONS } from './contracts.js';
2
- const ITEM_TEMPLATES = {
3
- capture: ['## Notes'],
4
- entity: ['## Context', '## Session Log'],
5
- project: ['## Purpose', '## Goals', '## Session Log'],
6
- };
7
- export function bodyTemplate(type, fields) {
8
- const sections = ITEM_TEMPLATES[type];
9
- if (!sections)
10
- throw new Error(`Unknown item type: ${type}`);
1
+ import { SUBTYPE_SECTIONS, DEFAULT_KNOWLEDGE_SUBTYPE, SESSION_SECTIONS, ENTITY_TYPES, getContract } from './contracts.js';
2
+ /** Render a body from a section list, injecting each field's text under the
3
+ * section that field writes into (contracts.CREATE_FIELD_SECTIONS names the
4
+ * pairs; a field whose section this template lacks is rejected upstream by
5
+ * `unreachableFields`, so an unplaced value never reaches here). */
6
+ function render(sections, content) {
11
7
  const lines = [];
12
8
  for (const section of sections) {
13
- lines.push(section);
14
- if (section === '## Purpose' && fields?.purpose) {
15
- lines.push('', fields.purpose);
16
- }
17
- if (section === '## Notes' && fields?.notes) {
18
- lines.push('', fields.notes);
19
- }
9
+ lines.push(`## ${section}`);
10
+ const text = content[section];
11
+ if (text)
12
+ lines.push('', text);
20
13
  lines.push('');
21
14
  }
22
15
  return lines.join('\n');
23
16
  }
17
+ export function bodyTemplate(type, fields) {
18
+ // The template IS the contract's default sections — the Document module
19
+ // derives its structural set from the same place, so the two can't drift.
20
+ if (!ENTITY_TYPES.includes(type))
21
+ throw new Error(`Unknown item type: ${type}`);
22
+ return render(getContract(type).defaultSections, { Purpose: fields?.purpose, Notes: fields?.notes });
23
+ }
24
24
  export function knowledgeBodyTemplate(subtype, fields) {
25
25
  if (subtype === 'session') {
26
26
  throw new Error("subtype 'session' is no longer supported for knowledge notes. " +
27
27
  "Use batch kind: 'session' instead. See knowledge-v2 migration.");
28
28
  }
29
- const sections = SUBTYPE_SECTIONS[subtype ?? 'insight'];
30
- const lines = [];
31
- for (const section of sections) {
32
- lines.push(`## ${section}`);
33
- if ((section === 'Synthesis' || section === 'Summary') && fields?.synthesis) {
34
- lines.push('', fields.synthesis);
35
- }
36
- lines.push('');
37
- }
38
- return lines.join('\n');
29
+ // 'Summary' was a second injection target here until sessions stopped being
30
+ // a knowledge subtype; no SUBTYPE_SECTIONS entry has carried it since — the
31
+ // session body is its own template below.
32
+ return render(SUBTYPE_SECTIONS[subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE], { Synthesis: fields?.synthesis });
33
+ }
34
+ /** Session bodies. Sessions have no TypeContract, but their headings are
35
+ * structural for Document splitting — so the list lives in contracts with
36
+ * every other section set, and this renders it. */
37
+ export function sessionBodyTemplate(fields) {
38
+ return render([...SESSION_SECTIONS], { Summary: fields?.synthesis });
39
39
  }
package/dist/types.d.ts CHANGED
@@ -1,11 +1,14 @@
1
- export type EntityType = 'capture' | 'entity' | 'project';
2
- export type EntityStatus = 'active' | 'paused' | 'done';
3
- export type ConfidenceLevel = 'observation' | 'pattern' | 'principle';
4
- export type SessionLogType = 'progress' | 'decision' | 'blocker' | 'research';
1
+ import type { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES } from './contracts.js';
2
+ export type EntityType = typeof ENTITY_TYPES[number];
3
+ export type EntityStatus = typeof ENTITY_STATUSES[number];
4
+ export type ConfidenceLevel = typeof CONFIDENCE_LEVELS[number];
5
+ export type SessionLogType = typeof SESSION_LOG_TYPES[number];
5
6
  export type ReadDepth = 'frontmatter' | 'summary' | 'full';
6
- export type QueryFilter = 'active' | 'paused' | 'done' | 'all' | 'stale' | 'capture';
7
+ export type QueryFilter = EntityStatus | 'all' | 'stale' | 'capture';
7
8
  export type LinkRelation = 'parent' | 'related';
8
- export type KnowledgeSubtype = 'insight' | 'plan';
9
+ export type KnowledgeSubtype = typeof VALID_SUBTYPES[number];
10
+ /** Plans are the one knowledge subtype with a lifecycle (see PLAN_STATUSES). */
11
+ export type PlanStatus = typeof PLAN_STATUSES[number];
9
12
  export interface EntityQueryOpts {
10
13
  filter?: QueryFilter;
11
14
  type?: EntityType;
@@ -26,12 +29,23 @@ export interface KnowledgeQueryOpts {
26
29
  domain?: string;
27
30
  confidence?: ConfidenceLevel;
28
31
  subtype?: KnowledgeSubtype;
32
+ since?: string;
33
+ before?: string;
34
+ createdSince?: string;
35
+ createdBefore?: string;
29
36
  tags?: string[];
30
37
  sort_by?: 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
31
38
  sort_order?: 'asc' | 'desc';
32
39
  limit?: number;
33
40
  offset?: number;
34
41
  }
42
+ /** The unified (`kind: 'all'`) query takes the entity filter set plus the
43
+ * knowledge-only narrowing flags. Status filters apply to the entity side
44
+ * only — knowledge notes carry no status and are never excluded by one. */
45
+ export interface UnifiedQueryOpts extends EntityQueryOpts {
46
+ kind?: 'entity' | 'knowledge' | 'all';
47
+ subtype?: KnowledgeSubtype;
48
+ }
35
49
  export interface EntityFrontmatter {
36
50
  name: string;
37
51
  type: EntityType;
@@ -58,7 +72,7 @@ export interface KnowledgeFrontmatter {
58
72
  'reinforcement-count'?: number;
59
73
  tags?: string[];
60
74
  'session-date'?: string;
61
- status?: 'active' | 'done';
75
+ status?: PlanStatus;
62
76
  'last-touched'?: string;
63
77
  }
64
78
  export interface SessionFrontmatter {
@@ -118,6 +132,7 @@ export interface UpdateFields {
118
132
  tags?: string[];
119
133
  related?: string[];
120
134
  synthesis?: string;
135
+ insight?: string;
121
136
  add_evidence?: string;
122
137
  add_source?: string;
123
138
  }
@@ -139,7 +154,9 @@ export interface CreateEntityFields {
139
154
  }
140
155
  export interface CreateKnowledgeFields {
141
156
  subtype?: KnowledgeSubtype;
157
+ status?: PlanStatus;
142
158
  synthesis?: string;
159
+ insight?: string;
143
160
  description?: string;
144
161
  domain?: string[];
145
162
  related?: string[];
@@ -241,8 +258,16 @@ export interface ChangelogEntry {
241
258
  export interface BatchResult {
242
259
  op: string;
243
260
  slug: string;
244
- status: 'ok' | 'error';
261
+ /** 'skipped' = the Op Spec green-lit the op but there was nothing to do
262
+ * (today: a duplicate session note) — no write, no error, batch continues.
263
+ * Dry-run reports the same disposition as a warning. */
264
+ status: 'ok' | 'error' | 'skipped';
245
265
  error?: string;
266
+ /** Why an op was skipped — same text the dry-run warning carries. */
267
+ warning?: string;
268
+ /** Error class when the failure — or the skip — maps to a specific HafezError
269
+ * code, so a single-op caller unwrapping this result keeps the right exit code. */
270
+ code?: 'SLUG_EXISTS';
246
271
  created?: boolean;
247
272
  }
248
273
  export type BatchOperation = {
@@ -290,6 +315,10 @@ export interface BatchOpValidation {
290
315
  created?: boolean;
291
316
  errors: string[];
292
317
  warning?: string;
318
+ /** Error class of the primary failure (NOT_FOUND / SLUG_EXISTS) when it
319
+ * maps to a specific HafezError code; single-op callers surface it as that
320
+ * code, batch-level failures stay VALIDATION_FAILED. */
321
+ primaryCode?: 'NOT_FOUND' | 'SLUG_EXISTS';
293
322
  }
294
323
  export interface BatchValidationReport {
295
324
  valid: boolean;
@@ -341,9 +370,7 @@ export interface Hafez {
341
370
  items: QueryResult[];
342
371
  total: number;
343
372
  }>;
344
- queryUnified(opts?: EntityQueryOpts & {
345
- kind?: 'entity' | 'knowledge' | 'all';
346
- }): Promise<{
373
+ queryUnified(opts?: UnifiedQueryOpts): Promise<{
347
374
  items: UnifiedResult[];
348
375
  total: number;
349
376
  }>;
package/dist/vault.d.ts CHANGED
@@ -25,13 +25,31 @@ export declare function scanVaultDir(dirPath: string): string[];
25
25
  /** Where an entity's overflow session-log entries are archived. */
26
26
  export declare function archiveLogPath(vaultPath: string, slug: string): string;
27
27
  export declare function resolveFilePath(vaultPath: string, slug: string, kind: VaultKind): string;
28
+ /** The file a slug names, or null — entities are resolved before knowledge,
29
+ * the one resolution order every operation sees. */
30
+ export declare function findVaultFile(vaultPath: string, slug: string): string | null;
31
+ /** Does this slug name a vault file? An invalid slug definitionally doesn't
32
+ * exist — reference checks (create parent/related, link targets, validate)
33
+ * must report "does not exist", not crash. validate() in particular exists to
34
+ * REPORT malformed refs. */
35
+ export declare function vaultFileExists(vaultPath: string, slug: string): boolean;
28
36
  export declare function kindFromPath(filePath: string): 'entity' | 'knowledge';
37
+ /** Fields that must always be arrays when present (may be scalar in
38
+ * hand-edited YAML). Parsing coerces them here; merge.ts unions them on
39
+ * conflict — one list, so a new list-valued field cannot be normalized on
40
+ * read and then overwritten wholesale on merge. */
41
+ export declare const ARRAY_FIELDS: string[];
29
42
  /** Full-kind path classification (kindFromPath only distinguishes the
30
43
  * indexed kinds — sessions default to knowledge there for compatibility).
31
44
  * Session files live directly in sessions/, so only the immediate parent
32
45
  * counts — matching anywhere in the path misclassifies every file of a
33
46
  * vault that happens to live under a directory named "sessions". */
34
47
  export declare function vaultKindFromPath(filePath: string): VaultKind;
48
+ /** Total kind classification for a vault-relative content file (anything
49
+ * matching VAULT_FILE_RE) — derived from KIND_DIRS, so adding a vault dir
50
+ * can't silently fall a new kind through to the wrong one. Throws on paths
51
+ * outside the vault dirs; callers gate on VAULT_FILE_RE first. */
52
+ export declare function vaultKindFromRelPath(relPath: string): VaultKind;
35
53
  export declare function parseFilePath(path: string): ParsedVaultFile;
36
54
  export declare function parseContent(content: string): {
37
55
  frontmatter: Record<string, any>;