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.
@@ -1,10 +1,10 @@
1
1
  // src/knowledge-index.ts
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
- import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
5
5
  import { join, basename } from 'path';
6
- import { parseFilePath } from './vault.js';
7
- import { deriveSummary } from './sections.js';
6
+ import { parseFilePath, scanVaultDir, kindDir } from './vault.js';
7
+ import { deriveSummary } from './document.js';
8
8
  import { ENTITY_STATUSES } from './contracts.js';
9
9
  function truncate(s) {
10
10
  return s.length > 100 ? s.slice(0, 97) + '...' : s;
@@ -19,9 +19,11 @@ function formatEntry(item) {
19
19
  /** Parse every .md directly in dir (non-recursive — auto-excludes archive/), skipping malformed files. */
20
20
  function scanParsed(dir) {
21
21
  const docs = [];
22
- for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
22
+ for (const file of scanVaultDir(dir)) {
23
23
  try {
24
- const { frontmatter: fm, body } = parseFilePath(join(dir, file));
24
+ const parsed = parseFilePath(file);
25
+ const fm = parsed.frontmatter;
26
+ const body = parsed.body;
25
27
  if (!fm.name)
26
28
  continue;
27
29
  docs.push({ slug: basename(file, '.md'), fm, body });
@@ -55,8 +57,8 @@ export function ensureLocalExclude(vaultPath) {
55
57
  catch { /* best effort — never fail a vault write over ignore hygiene */ }
56
58
  }
57
59
  export function generateVaultIndex(vaultPath) {
58
- const entitiesDir = join(vaultPath, 'entities');
59
- const knowledgeDir = join(vaultPath, 'knowledge');
60
+ const entitiesDir = join(vaultPath, kindDir('entity'));
61
+ const knowledgeDir = join(vaultPath, kindDir('knowledge'));
60
62
  const hasEntities = existsSync(entitiesDir);
61
63
  const hasKnowledge = existsSync(knowledgeDir);
62
64
  if (!hasEntities && !hasKnowledge)
package/dist/merge.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/merge.ts
2
2
  import { parseContent, serializeFile } from './vault.js';
3
- import { parseSessionLogHeading } from './parser.js';
3
+ import { parseSessionLogHeading, splitStructural } from './document.js';
4
+ import { APPEND_ONLY_SECTIONS } from './contracts.js';
4
5
  // Fields that are ISO date strings — latest date wins
5
6
  const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
6
7
  // Fields that are numeric counters — max wins
@@ -51,41 +52,28 @@ function unionArrays(a, b) {
51
52
  const arrB = Array.isArray(b) ? b : b ? [b] : [];
52
53
  return [...new Set([...arrB, ...arrA])];
53
54
  }
54
- function splitSections(body) {
55
- const parts = body.split(/\n(?=## )/g);
56
- const sections = [];
57
- let preamble = '';
58
- for (const part of parts) {
59
- if (part.startsWith('## ')) {
60
- const nlIdx = part.indexOf('\n');
61
- const heading = nlIdx === -1 ? part : part.slice(0, nlIdx);
62
- const content = nlIdx === -1 ? '' : part.slice(nlIdx + 1);
63
- sections.push({ heading: heading.trim(), content: content.trim() });
64
- }
65
- else {
66
- preamble = part.trim();
67
- }
68
- }
69
- return { preamble, sections };
70
- }
55
+ // --- Body merge ---
71
56
  function mergeBody(remoteBody, localBody, localWins) {
72
57
  if (remoteBody === localBody)
73
58
  return localBody;
74
- const remote = splitSections(remoteBody);
75
- const local = splitSections(localBody);
59
+ // Structural split (see document.ts): sections are atomic — a user ##
60
+ // heading inside a Brief or a session-log entry body stays with its
61
+ // section instead of being split out and merged under its own policy.
62
+ const remote = splitStructural(remoteBody);
63
+ const local = splitStructural(localBody);
76
64
  // Build merged sections starting from local (local section order is canonical)
77
65
  const localHeadings = new Set(local.sections.map(s => s.heading));
78
66
  const mergedSections = [];
79
67
  for (const ls of local.sections) {
80
68
  const rs = remote.sections.find(s => s.heading === ls.heading);
81
- if (ls.heading === '## Session Log' && rs) {
69
+ if (ls.heading === 'Session Log' && rs) {
82
70
  mergedSections.push({ heading: ls.heading, content: mergeSessionLogs(rs.content, ls.content) });
83
71
  }
84
- else if (ls.heading === '## Next Actions' && rs) {
72
+ else if (ls.heading === 'Next Actions' && rs) {
85
73
  mergedSections.push({ heading: ls.heading, content: mergeNextActions(rs.content, ls.content) });
86
74
  }
87
- else if ((ls.heading === '## Evidence' || ls.heading === '## Sources') && rs) {
88
- // Append-only sections: union lines from both sides, dedup identical
75
+ else if (APPEND_ONLY_SECTIONS.has(ls.heading) && rs) {
76
+ // Append-only sections (contracts.ts): union lines from both sides, dedup identical
89
77
  mergedSections.push({ heading: ls.heading, content: mergeAppendOnlyLines(rs.content, ls.content) });
90
78
  }
91
79
  else if (rs && !localWins) {
@@ -106,7 +94,7 @@ function mergeBody(remoteBody, localBody, localWins) {
106
94
  const preamble = localWins
107
95
  ? (local.preamble || remote.preamble)
108
96
  : (remote.preamble || local.preamble);
109
- const parts = [preamble, ...mergedSections.map(s => `${s.heading}\n\n${s.content}`)].filter(Boolean);
97
+ const parts = [preamble, ...mergedSections.map(s => `## ${s.heading}\n\n${s.content}`)].filter(Boolean);
110
98
  return parts.join('\n\n');
111
99
  }
112
100
  /** Union merge for append-only free-text sections: local lines first, then
package/dist/query.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare function queryChildren(index: HafezIndex, parentSlug: string): {
9
9
  total: number;
10
10
  };
11
11
  export declare function queryRelatedTo(index: HafezIndex, slug: string, relation?: string): {
12
- items: (QueryResult | KnowledgeQueryResult)[];
12
+ items: UnifiedResult[];
13
13
  total: number;
14
14
  };
15
15
  export declare function queryKnowledge(index: HafezIndex, opts?: KnowledgeQueryOpts): {
package/dist/query.js CHANGED
@@ -89,7 +89,9 @@ export function queryRelatedTo(index, slug, relation) {
89
89
  index.syncIfStale();
90
90
  const { items, total } = index.queryItems({ relatedTo: slug, relation });
91
91
  return {
92
- items: items.map(i => i.kind === 'entity' ? toQueryResult(i.slug, i) : toKnowledgeResult(i.slug, i)),
92
+ items: items.map(i => i.kind === 'entity'
93
+ ? { ...toQueryResult(i.slug, i), kind: 'entity' }
94
+ : { ...toKnowledgeResult(i.slug, i), kind: 'knowledge' }),
93
95
  total,
94
96
  };
95
97
  }
package/dist/types.d.ts CHANGED
@@ -61,7 +61,30 @@ export interface KnowledgeFrontmatter {
61
61
  status?: 'active' | 'done';
62
62
  'last-touched'?: string;
63
63
  }
64
+ export interface SessionFrontmatter {
65
+ name: string;
66
+ created: string;
67
+ 'session-date'?: string;
68
+ related?: string[];
69
+ }
70
+ /** What parseFilePath returns: the kind comes from Layout (the file's
71
+ * directory), so consumers switch on `kind` instead of sniffing keys. */
72
+ export type ParsedVaultFile = {
73
+ kind: 'entity';
74
+ frontmatter: EntityFrontmatter;
75
+ body: string;
76
+ } | {
77
+ kind: 'knowledge';
78
+ frontmatter: KnowledgeFrontmatter;
79
+ body: string;
80
+ } | {
81
+ kind: 'session';
82
+ frontmatter: SessionFrontmatter;
83
+ body: string;
84
+ };
64
85
  export interface ParsedFile<T = EntityFrontmatter | KnowledgeFrontmatter> {
86
+ /** Populated by read(): which side of the vault the slug resolved to. */
87
+ kind?: 'entity' | 'knowledge';
65
88
  frontmatter: T;
66
89
  body: string;
67
90
  }
@@ -112,6 +135,7 @@ export interface CreateEntityFields {
112
135
  tags?: string[];
113
136
  brief?: string;
114
137
  add_action?: string;
138
+ add_actions?: string[];
115
139
  }
116
140
  export interface CreateKnowledgeFields {
117
141
  subtype?: KnowledgeSubtype;
@@ -259,6 +283,18 @@ export type BatchOperation = {
259
283
  slug: string;
260
284
  target: 'entity' | 'project' | 'knowledge';
261
285
  };
286
+ export interface BatchOpValidation {
287
+ index: number;
288
+ op: string;
289
+ slug: string;
290
+ created?: boolean;
291
+ errors: string[];
292
+ warning?: string;
293
+ }
294
+ export interface BatchValidationReport {
295
+ valid: boolean;
296
+ operations: BatchOpValidation[];
297
+ }
262
298
  export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
263
299
  export declare class HafezError extends Error {
264
300
  code: HafezErrorCode;
@@ -268,10 +304,34 @@ export declare class HafezError extends Error {
268
304
  export interface GitConfig {
269
305
  push?: boolean;
270
306
  }
307
+ /**
308
+ * The persistence seam (see CONTEXT.md: Journal) — everything core needs
309
+ * from the history layer behind vault mutations. Git satisfies it in
310
+ * production (src/git.ts); tests may inject an in-memory adapter.
311
+ */
312
+ export interface Journal {
313
+ /** Persist one logical change: stage written+deleted paths (vault-relative)
314
+ * and commit under message. Must clean up its own partial state (e.g.
315
+ * staged files) before rethrowing on a commit-stage failure; a push-stage
316
+ * failure surfaces as GIT_PUSH_FAILED with the commit kept. */
317
+ commit(written: string[], deleted: string[], message: string): Promise<void>;
318
+ /** Bidirectional sync with the remote, if any. */
319
+ sync(): Promise<{
320
+ pulled: boolean;
321
+ pushed: boolean;
322
+ remote: boolean;
323
+ }>;
324
+ /** History-derived changelog for entities/ and knowledge/ since a date. */
325
+ changelog(since: string): Promise<ChangelogEntry[]>;
326
+ /** Newest history timestamp per vault file (modified = any change, added = creation). */
327
+ fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
328
+ }
271
329
  export interface HafezConfig {
272
330
  vaultPath: string;
273
331
  readOnly?: boolean;
274
332
  git?: GitConfig;
333
+ /** Override the persistence layer. Defaults to the git adapter. */
334
+ persistence?: Journal;
275
335
  onWrite?: (slug: string, operation: string) => void;
276
336
  }
277
337
  export interface Hafez {
@@ -294,6 +354,7 @@ export interface Hafez {
294
354
  unlink(slug: string, target: string, relation: LinkRelation): Promise<void>;
295
355
  promote(slug: string, target: 'entity' | 'project' | 'knowledge'): Promise<string>;
296
356
  batch(operations: BatchOperation[]): Promise<BatchResult[]>;
357
+ validateBatch(operations: BatchOperation[]): Promise<BatchValidationReport>;
297
358
  sync(): Promise<{
298
359
  pulled: boolean;
299
360
  pushed: boolean;
@@ -304,7 +365,7 @@ export interface Hafez {
304
365
  total: number;
305
366
  }>;
306
367
  related_to(slug: string, relation?: string): Promise<{
307
- items: (QueryResult | KnowledgeQueryResult)[];
368
+ items: UnifiedResult[];
308
369
  total: number;
309
370
  }>;
310
371
  query_knowledge(opts?: KnowledgeQueryOpts): Promise<{
package/dist/vault.d.ts CHANGED
@@ -1,10 +1,38 @@
1
+ import type { ParsedVaultFile } from './types.js';
1
2
  export declare function slugify(name: string): string;
2
- export declare function resolveFilePath(vaultPath: string, slug: string, kind: 'entity' | 'knowledge'): string;
3
- export declare function slugExists(vaultPath: string, slug: string): boolean;
4
- export declare function parseFilePath(path: string): {
5
- frontmatter: Record<string, any>;
6
- body: string;
7
- };
3
+ export type VaultKind = 'entity' | 'knowledge' | 'session';
4
+ /** Kinds that live in the SQLite read index (sessions are files-and-git only). */
5
+ export declare const INDEXED_KINDS: ReadonlyArray<'entity' | 'knowledge'>;
6
+ /** Vault directory for a kind, relative to the vault root. */
7
+ export declare function kindDir(kind: VaultKind): string;
8
+ /** A vault content file: anything a sync/merge must treat semantically. */
9
+ export declare const VAULT_FILE_RE: RegExp;
10
+ /** True for vault-relative paths whose kind lives in the read index. */
11
+ export declare function isIndexedRelPath(relPath: string): boolean;
12
+ /** History pathspecs covering the indexed dirs (changelog, file times). */
13
+ export declare const INDEXED_DIR_PATHSPECS: ReadonlyArray<string>;
14
+ /** Classify a vault-relative path into an indexed kind + slug; null outside
15
+ * the indexed dirs (sessions, non-vault files). Nested paths keep their
16
+ * historical shape: entities/archive/x-log.md → entity 'archive/x-log'. */
17
+ export declare function classifyIndexedRelPath(relPath: string): {
18
+ kind: 'entity' | 'knowledge';
19
+ slug: string;
20
+ } | null;
21
+ /** All .md files directly in dir — non-recursive (archive/ subdirs are
22
+ * invisible by construction) and dotfile-free: dotfile slugs are rejected by
23
+ * resolveFilePath, so listing them would surface files no operation can reach. */
24
+ export declare function scanVaultDir(dirPath: string): string[];
25
+ /** Where an entity's overflow session-log entries are archived. */
26
+ export declare function archiveLogPath(vaultPath: string, slug: string): string;
27
+ export declare function resolveFilePath(vaultPath: string, slug: string, kind: VaultKind): string;
28
+ export declare function kindFromPath(filePath: string): 'entity' | 'knowledge';
29
+ /** Full-kind path classification (kindFromPath only distinguishes the
30
+ * indexed kinds — sessions default to knowledge there for compatibility).
31
+ * Session files live directly in sessions/, so only the immediate parent
32
+ * counts — matching anywhere in the path misclassifies every file of a
33
+ * vault that happens to live under a directory named "sessions". */
34
+ export declare function vaultKindFromPath(filePath: string): VaultKind;
35
+ export declare function parseFilePath(path: string): ParsedVaultFile;
8
36
  export declare function parseContent(content: string): {
9
37
  frontmatter: Record<string, any>;
10
38
  body: string;
package/dist/vault.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/vault.ts
2
2
  import matter from 'gray-matter';
3
3
  import yaml from 'js-yaml';
4
- import { readFileSync, existsSync } from 'fs';
4
+ import { readFileSync, readdirSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { HafezError } from './types.js';
7
7
  // Prevent gray-matter from auto-coercing YAML date strings into JS Date objects
@@ -16,6 +16,48 @@ export function slugify(name) {
16
16
  .replace(/^-|-$/g, '')
17
17
  .replace(/-{2,}/g, '-');
18
18
  }
19
+ const KIND_DIRS = { entity: 'entities', knowledge: 'knowledge', session: 'sessions' };
20
+ /** Kinds that live in the SQLite read index (sessions are files-and-git only). */
21
+ export const INDEXED_KINDS = ['entity', 'knowledge'];
22
+ /** Vault directory for a kind, relative to the vault root. */
23
+ export function kindDir(kind) {
24
+ return KIND_DIRS[kind];
25
+ }
26
+ /** A vault content file: anything a sync/merge must treat semantically. */
27
+ export const VAULT_FILE_RE = new RegExp(`^(?:${Object.values(KIND_DIRS).join('|')})/.+\\.md$`);
28
+ /** True for vault-relative paths whose kind lives in the read index. */
29
+ export function isIndexedRelPath(relPath) {
30
+ return INDEXED_KINDS.some(k => relPath.startsWith(`${KIND_DIRS[k]}/`));
31
+ }
32
+ /** History pathspecs covering the indexed dirs (changelog, file times). */
33
+ export const INDEXED_DIR_PATHSPECS = INDEXED_KINDS.map(k => `${KIND_DIRS[k]}/`);
34
+ /** Classify a vault-relative path into an indexed kind + slug; null outside
35
+ * the indexed dirs (sessions, non-vault files). Nested paths keep their
36
+ * historical shape: entities/archive/x-log.md → entity 'archive/x-log'. */
37
+ export function classifyIndexedRelPath(relPath) {
38
+ for (const kind of INDEXED_KINDS) {
39
+ const prefix = `${KIND_DIRS[kind]}/`;
40
+ if (relPath.startsWith(prefix)) {
41
+ return { kind, slug: relPath.replace(prefix, '').replace('.md', '') };
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ /** All .md files directly in dir — non-recursive (archive/ subdirs are
47
+ * invisible by construction) and dotfile-free: dotfile slugs are rejected by
48
+ * resolveFilePath, so listing them would surface files no operation can reach. */
49
+ export function scanVaultDir(dirPath) {
50
+ try {
51
+ return readdirSync(dirPath).filter(f => f.endsWith('.md') && !f.startsWith('.')).map(f => join(dirPath, f));
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ /** Where an entity's overflow session-log entries are archived. */
58
+ export function archiveLogPath(vaultPath, slug) {
59
+ return join(vaultPath, KIND_DIRS.entity, 'archive', `${slug}-log.md`);
60
+ }
19
61
  // Slugs are path components: anything with separators (or a leading dot, or a
20
62
  // Windows drive colon) could escape the vault — "../../outside/secret" reads,
21
63
  // writes, and deletes files anywhere on disk. Block only those; spaces and
@@ -25,12 +67,14 @@ export function resolveFilePath(vaultPath, slug, kind) {
25
67
  if (!slug || slug.startsWith('.') || /[/\\:\0]/.test(slug)) {
26
68
  throw new HafezError('VALIDATION_FAILED', `Invalid slug: '${slug}'`);
27
69
  }
28
- const dir = kind === 'entity' ? 'entities' : 'knowledge';
29
- return join(vaultPath, dir, `${slug}.md`);
70
+ return join(vaultPath, KIND_DIRS[kind], `${slug}.md`);
30
71
  }
31
- export function slugExists(vaultPath, slug) {
32
- return existsSync(resolveFilePath(vaultPath, slug, 'entity'))
33
- || existsSync(resolveFilePath(vaultPath, slug, 'knowledge'));
72
+ // Separator-agnostic: OS paths use '/' or '\' depending on platform, and a
73
+ // POSIX-only check silently classifies every entity as knowledge on Windows
74
+ // (all updates then fail on kind rules). Exported pure so win32 paths are
75
+ // unit-testable from any platform.
76
+ export function kindFromPath(filePath) {
77
+ return /[/\\]entities[/\\]/.test(filePath) ? 'entity' : 'knowledge';
34
78
  }
35
79
  // Fields that must always be arrays when present (may be scalar in hand-edited YAML)
36
80
  const ARRAY_FIELDS = ['related', 'tags', 'domain'];
@@ -42,10 +86,30 @@ function normalizeFrontmatter(fm) {
42
86
  }
43
87
  return fm;
44
88
  }
89
+ /** Full-kind path classification (kindFromPath only distinguishes the
90
+ * indexed kinds — sessions default to knowledge there for compatibility).
91
+ * Session files live directly in sessions/, so only the immediate parent
92
+ * counts — matching anywhere in the path misclassifies every file of a
93
+ * vault that happens to live under a directory named "sessions". */
94
+ export function vaultKindFromPath(filePath) {
95
+ if (new RegExp(`[/\\\\]${KIND_DIRS.session}[/\\\\][^/\\\\]+$`).test(filePath))
96
+ return 'session';
97
+ return kindFromPath(filePath);
98
+ }
45
99
  export function parseFilePath(path) {
46
100
  const content = readFileSync(path, 'utf-8');
47
101
  const { data, content: body } = matter(content, MATTER_OPTIONS);
48
- return { frontmatter: normalizeFrontmatter(data), body: body.trim() };
102
+ const fm = normalizeFrontmatter(data);
103
+ const trimmed = body.trim();
104
+ const kind = vaultKindFromPath(path);
105
+ // Types over classes (C7): the frontmatter is normalized once here and
106
+ // asserted to the kind's shape — validation of required fields stays with
107
+ // schema.ts and the index builder, which tolerate malformed files.
108
+ if (kind === 'entity')
109
+ return { kind, frontmatter: fm, body: trimmed };
110
+ if (kind === 'session')
111
+ return { kind, frontmatter: fm, body: trimmed };
112
+ return { kind: 'knowledge', frontmatter: fm, body: trimmed };
49
113
  }
50
114
  export function parseContent(content) {
51
115
  const { data, content: body } = matter(content, MATTER_OPTIONS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hafez",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,7 +17,7 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "build": "rm -rf dist && tsc",
20
+ "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
21
21
  "install:local": "chmod +x \"$(pwd)/dist/cli.js\" && mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && rm -f ~/.claude/skills/hafez/SKILL.md && mkdir -p ~/.claude/skills/hafez && cp \"$(pwd)/skill/SKILL.md\" ~/.claude/skills/hafez/SKILL.md",
22
22
  "pretest": "npm run build",
23
23
  "test": "vitest run",
@@ -34,15 +34,15 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/better-sqlite3": "^7.6.13",
37
- "@types/proper-lockfile": "^4.1.4",
38
37
  "@types/js-yaml": "^4.0.9",
39
38
  "@types/node": "^25.4.0",
39
+ "@types/proper-lockfile": "^4.1.4",
40
40
  "tsx": "^4.21.0",
41
41
  "typescript": "^5.9.3",
42
42
  "vitest": "^4.0.18"
43
43
  },
44
44
  "license": "MIT",
45
- "description": "Agent-native personal knowledge vault: markdown + YAML frontmatter, SQLite FTS5 search, git-backed",
45
+ "description": "A knowledge and project vault shared between you and your agents — plain markdown + git, schema-enforced writes, FTS5 search, operated by agents through a CLI",
46
46
  "keywords": [
47
47
  "knowledge-base",
48
48
  "knowledge-vault",
package/skill/SKILL.md CHANGED
@@ -41,7 +41,7 @@ Quick reference for common operations:
41
41
  | Add evidence to knowledge | `hafez update <slug> --add-evidence "text"` |
42
42
  | Add source to knowledge | `hafez update <slug> --add-source "url"` |
43
43
  | Export OKF bundle | `hafez export --okf [--out <dir>]` (re-export overwrites, never deletes) |
44
- | Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` |
44
+ | Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` (no pipes? `--file` on both) |
45
45
 
46
46
  Note: `--insight` is an alias for `--synthesis`.
47
47
 
@@ -143,6 +143,10 @@ echo '[
143
143
 
144
144
  Batch output includes created slugs: `Batch complete: 3 operations (created: new-thing, insight)`
145
145
 
146
+ On shells that can't pipe stdin to native executables (Windows PowerShell 5.1), write the payload to a file and use `hafez batch --file payload.json` — same JSON, same behavior. (`cmd /c "hafez batch < payload.json"` also works.)
147
+
148
+ **Slug contract.** Slugs derive deterministically from names: lowercase, non-alphanumeric runs become single `-` (`"Auth Refactor!"` → `auth-refactor`). Batch executes sequentially, so later ops can reference the slugs of earlier creates in the same payload — create-then-link works in one batch. Collisions hard-error, so a wrong prediction can't touch the wrong document; `hafez batch --dry-run` reports the derived slug for every create op if you want to verify first.
149
+
146
150
  For a single operation, individual commands are fine — they auto-sync.
147
151
 
148
152
  ## Session End — Digest
@@ -163,6 +167,8 @@ echo '{
163
167
  }' | hafez digest | hafez batch
164
168
  ```
165
169
 
170
+ On shells that can't pipe (PowerShell 5.1), use files instead — digest stays inspectable in the middle: `hafez digest --file summary.json > ops.json`, then `hafez batch --file ops.json`.
171
+
166
172
  Digest adds a session log entry to each touched entity and creates a session note linking them all. Authoritative field docs: `hafez schema digest`.
167
173
 
168
174
  Digest itself is pure — it only prints batch JSON; `hafez batch` is what writes. If you are unsure the slugs exist, run `hafez digest` alone first: unknown slugs warn on stderr (they are omitted from the output). Fix `entities_touched`, regenerate, and pipe to batch **once** — re-running the full pipe after a successful batch creates a duplicate session note.
package/dist/parser.d.ts DELETED
@@ -1,18 +0,0 @@
1
- import type { SessionLogEntry, SessionLogType } from './types.js';
2
- export declare function parseSessionLogHeading(line: string): {
3
- date: string;
4
- agent: string;
5
- type: SessionLogType;
6
- } | null;
7
- export declare function formatSessionLogEntry(entry: SessionLogEntry): string;
8
- export declare function countSessionLogEntries(body: string): number;
9
- export declare function parseSessionLog(body: string): Array<{
10
- date: string;
11
- agent: string;
12
- type: SessionLogType;
13
- summary: string;
14
- }>;
15
- export declare function extractOldestSessionLogEntry(body: string): {
16
- entry: string;
17
- remaining: string;
18
- } | null;
package/dist/parser.js DELETED
@@ -1,68 +0,0 @@
1
- import { findNextStructuralHeading } from './sections.js';
2
- const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
3
- export function parseSessionLogHeading(line) {
4
- const match = line.match(HEADING_RE);
5
- if (!match)
6
- return null;
7
- return {
8
- date: match[1],
9
- agent: match[2],
10
- type: match[3] || 'progress'
11
- };
12
- }
13
- export function formatSessionLogEntry(entry) {
14
- const today = new Date().toISOString().slice(0, 10);
15
- const lines = [`### ${today} — ${entry.agent} [${entry.type}]`, `Summary: ${entry.summary}`];
16
- if (entry.body)
17
- lines.push(entry.body);
18
- return lines.join('\n');
19
- }
20
- export function countSessionLogEntries(body) {
21
- return (body.match(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm) || []).length;
22
- }
23
- export function parseSessionLog(body) {
24
- // Inline section extraction to avoid circular import with sections.ts
25
- const idx = body.indexOf('## Session Log');
26
- if (idx === -1)
27
- return [];
28
- const afterPos = idx + '## Session Log'.length;
29
- // Find end: next structural ## heading or end of string
30
- const nextH = findNextStructuralHeading(body, afterPos);
31
- const sectionContent = nextH !== -1 ? body.slice(afterPos, nextH) : body.slice(afterPos);
32
- const entries = [];
33
- const lines = sectionContent.split('\n');
34
- let current = null;
35
- let summaryLine = null;
36
- for (const line of lines) {
37
- const heading = parseSessionLogHeading(line);
38
- if (heading) {
39
- if (current && summaryLine) {
40
- entries.push({ ...current, summary: summaryLine });
41
- }
42
- current = heading;
43
- summaryLine = null;
44
- }
45
- else if (current && !summaryLine && line.startsWith('Summary: ')) {
46
- summaryLine = line.slice('Summary: '.length);
47
- }
48
- }
49
- if (current && summaryLine) {
50
- entries.push({ ...current, summary: summaryLine });
51
- }
52
- return entries;
53
- }
54
- export function extractOldestSessionLogEntry(body) {
55
- const sectionStart = body.indexOf('## Session Log');
56
- if (sectionStart === -1)
57
- return null;
58
- const afterHeader = body.slice(sectionStart + '## Session Log'.length);
59
- const entryStarts = [...afterHeader.matchAll(/^### \d{4}-\d{2}-\d{2} (?:—|--)/gm)];
60
- if (entryStarts.length < 2)
61
- return null;
62
- // Entries are newest-first in the file, so the oldest is the last match
63
- const lastIdx = entryStarts.length - 1;
64
- const oldestStart = entryStarts[lastIdx].index;
65
- const entry = afterHeader.slice(oldestStart).trim();
66
- const remaining = body.slice(0, sectionStart + '## Session Log'.length) + afterHeader.slice(0, oldestStart);
67
- return { entry, remaining };
68
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * Known structural section headings in Hafez entities and knowledge notes.
3
- * Derived from contracts.ts (defaultSections + optionalSections) plus
4
- * auto-generated sections not tracked in contracts (Related, Summary).
5
- * Only these are treated as section boundaries. Any other `## ` heading
6
- * inside section content (e.g. in a brief or session log) is preserved as-is.
7
- */
8
- export declare const STRUCTURAL_SECTIONS: Set<string>;
9
- /**
10
- * Find the next structural `## Heading` boundary after a given position.
11
- * Skips `## ` headings that are not known structural sections (content headings).
12
- * Returns the index of the `\n` before the `## `, or -1 if no structural heading found.
13
- */
14
- export declare function findNextStructuralHeading(body: string, from: number): number;
15
- /**
16
- * Find a ## Heading section and its content.
17
- * Returns { start, end, content } or null if not found.
18
- * Matches heading exactly (e.g. "## Brief" won't match "## Briefing").
19
- * Only treats known structural headings as section boundaries -- content
20
- * inside a section may contain arbitrary `## ` headings without corruption.
21
- */
22
- export declare function findSection(body: string, heading: string): {
23
- start: number;
24
- end: number;
25
- content: string;
26
- } | null;
27
- export declare function getBrief(body: string): string | null;
28
- export declare function setBrief(body: string, content: string): string;
29
- export declare function removeBrief(body: string): string;
30
- export declare function getNextActions(body: string): string[];
31
- export declare function addNextAction(body: string, action: string): string;
32
- export declare function completeNextAction(body: string, query: string): {
33
- matched: string;
34
- body: string;
35
- };
36
- export declare function removeNextAction(body: string, query: string): {
37
- matched: string;
38
- body: string;
39
- };
40
- export declare function clearNextActions(body: string): string;
41
- export declare function firstNonEmptyLine(s: string | null | undefined): string;
42
- /**
43
- * One-line summary for a doc: frontmatter description first, then the first
44
- * non-empty line of the kind's primary section (entity: Brief; knowledge:
45
- * Synthesis, falling back to Goal for plans; session: Summary).
46
- * Shared by the vault index and the OKF export so the two surfaces agree.
47
- */
48
- export declare function deriveSummary(kind: 'entity' | 'knowledge' | 'session', fm: Record<string, any>, body: string): string;
49
- /** Matches [[slug]] or [[slug|display text]], capturing slug in group 1. Non-global — wrap with `new RegExp(WIKI_LINK_RE.source, 'g')` for iteration. */
50
- export declare const WIKI_LINK_RE: RegExp;
51
- export declare function syncRelatedSection(body: string, frontmatter: {
52
- parent?: string;
53
- related?: string[];
54
- }): string;