hafez 1.2.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.
Files changed (52) hide show
  1. package/README.md +67 -9
  2. package/dist/apply.d.ts +30 -0
  3. package/dist/apply.js +582 -0
  4. package/dist/batch-ops.d.ts +470 -0
  5. package/dist/batch-ops.js +446 -0
  6. package/dist/cli/args.d.ts +19 -0
  7. package/dist/cli/args.js +53 -0
  8. package/dist/cli/batch-errors.js +6 -3
  9. package/dist/cli/commands.d.ts +24 -108
  10. package/dist/cli/commands.js +297 -313
  11. package/dist/cli/help-agent.d.ts +0 -5
  12. package/dist/cli/help-agent.js +44 -5
  13. package/dist/cli/index.d.ts +41 -0
  14. package/dist/cli/index.js +172 -172
  15. package/dist/cli/resolve-vault.js +2 -1
  16. package/dist/cli/schema-introspect.d.ts +1 -1
  17. package/dist/cli/schema-introspect.js +6 -22
  18. package/dist/cli/upgrade.d.ts +42 -0
  19. package/dist/cli/upgrade.js +305 -0
  20. package/dist/cli.js +0 -0
  21. package/dist/contracts.d.ts +55 -6
  22. package/dist/contracts.js +110 -23
  23. package/dist/dates.d.ts +10 -0
  24. package/dist/dates.js +27 -0
  25. package/dist/db.d.ts +27 -2
  26. package/dist/db.js +318 -56
  27. package/dist/digest.js +2 -0
  28. package/dist/document.d.ts +117 -0
  29. package/dist/document.js +578 -0
  30. package/dist/export-okf.js +48 -34
  31. package/dist/git.d.ts +7 -8
  32. package/dist/git.js +198 -63
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +302 -915
  35. package/dist/knowledge-index.d.ts +23 -1
  36. package/dist/knowledge-index.js +71 -13
  37. package/dist/merge.d.ts +1 -1
  38. package/dist/merge.js +106 -86
  39. package/dist/schema.js +4 -4
  40. package/dist/templates.d.ts +6 -0
  41. package/dist/templates.js +27 -27
  42. package/dist/types.d.ts +86 -12
  43. package/dist/vault.d.ts +51 -6
  44. package/dist/vault.js +114 -11
  45. package/package.json +2 -2
  46. package/skill/SKILL.md +51 -110
  47. package/dist/parser.d.ts +0 -18
  48. package/dist/parser.js +0 -68
  49. package/dist/query.d.ts +0 -24
  50. package/dist/query.js +0 -167
  51. package/dist/sections.d.ts +0 -54
  52. package/dist/sections.js +0 -297
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,10 +72,33 @@ 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
  }
78
+ export interface SessionFrontmatter {
79
+ name: string;
80
+ created: string;
81
+ 'session-date'?: string;
82
+ related?: string[];
83
+ }
84
+ /** What parseFilePath returns: the kind comes from Layout (the file's
85
+ * directory), so consumers switch on `kind` instead of sniffing keys. */
86
+ export type ParsedVaultFile = {
87
+ kind: 'entity';
88
+ frontmatter: EntityFrontmatter;
89
+ body: string;
90
+ } | {
91
+ kind: 'knowledge';
92
+ frontmatter: KnowledgeFrontmatter;
93
+ body: string;
94
+ } | {
95
+ kind: 'session';
96
+ frontmatter: SessionFrontmatter;
97
+ body: string;
98
+ };
64
99
  export interface ParsedFile<T = EntityFrontmatter | KnowledgeFrontmatter> {
100
+ /** Populated by read(): which side of the vault the slug resolved to. */
101
+ kind?: 'entity' | 'knowledge';
65
102
  frontmatter: T;
66
103
  body: string;
67
104
  }
@@ -95,6 +132,7 @@ export interface UpdateFields {
95
132
  tags?: string[];
96
133
  related?: string[];
97
134
  synthesis?: string;
135
+ insight?: string;
98
136
  add_evidence?: string;
99
137
  add_source?: string;
100
138
  }
@@ -116,7 +154,9 @@ export interface CreateEntityFields {
116
154
  }
117
155
  export interface CreateKnowledgeFields {
118
156
  subtype?: KnowledgeSubtype;
157
+ status?: PlanStatus;
119
158
  synthesis?: string;
159
+ insight?: string;
120
160
  description?: string;
121
161
  domain?: string[];
122
162
  related?: string[];
@@ -218,8 +258,16 @@ export interface ChangelogEntry {
218
258
  export interface BatchResult {
219
259
  op: string;
220
260
  slug: string;
221
- 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';
222
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';
223
271
  created?: boolean;
224
272
  }
225
273
  export type BatchOperation = {
@@ -267,6 +315,10 @@ export interface BatchOpValidation {
267
315
  created?: boolean;
268
316
  errors: string[];
269
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';
270
322
  }
271
323
  export interface BatchValidationReport {
272
324
  valid: boolean;
@@ -281,10 +333,34 @@ export declare class HafezError extends Error {
281
333
  export interface GitConfig {
282
334
  push?: boolean;
283
335
  }
336
+ /**
337
+ * The persistence seam (see CONTEXT.md: Journal) — everything core needs
338
+ * from the history layer behind vault mutations. Git satisfies it in
339
+ * production (src/git.ts); tests may inject an in-memory adapter.
340
+ */
341
+ export interface Journal {
342
+ /** Persist one logical change: stage written+deleted paths (vault-relative)
343
+ * and commit under message. Must clean up its own partial state (e.g.
344
+ * staged files) before rethrowing on a commit-stage failure; a push-stage
345
+ * failure surfaces as GIT_PUSH_FAILED with the commit kept. */
346
+ commit(written: string[], deleted: string[], message: string): Promise<void>;
347
+ /** Bidirectional sync with the remote, if any. */
348
+ sync(): Promise<{
349
+ pulled: boolean;
350
+ pushed: boolean;
351
+ remote: boolean;
352
+ }>;
353
+ /** History-derived changelog for entities/ and knowledge/ since a date. */
354
+ changelog(since: string): Promise<ChangelogEntry[]>;
355
+ /** Newest history timestamp per vault file (modified = any change, added = creation). */
356
+ fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
357
+ }
284
358
  export interface HafezConfig {
285
359
  vaultPath: string;
286
360
  readOnly?: boolean;
287
361
  git?: GitConfig;
362
+ /** Override the persistence layer. Defaults to the git adapter. */
363
+ persistence?: Journal;
288
364
  onWrite?: (slug: string, operation: string) => void;
289
365
  }
290
366
  export interface Hafez {
@@ -294,9 +370,7 @@ export interface Hafez {
294
370
  items: QueryResult[];
295
371
  total: number;
296
372
  }>;
297
- queryUnified(opts?: EntityQueryOpts & {
298
- kind?: 'entity' | 'knowledge' | 'all';
299
- }): Promise<{
373
+ queryUnified(opts?: UnifiedQueryOpts): Promise<{
300
374
  items: UnifiedResult[];
301
375
  total: number;
302
376
  }>;
@@ -318,7 +392,7 @@ export interface Hafez {
318
392
  total: number;
319
393
  }>;
320
394
  related_to(slug: string, relation?: string): Promise<{
321
- items: (QueryResult | KnowledgeQueryResult)[];
395
+ items: UnifiedResult[];
322
396
  total: number;
323
397
  }>;
324
398
  query_knowledge(opts?: KnowledgeQueryOpts): Promise<{
package/dist/vault.d.ts CHANGED
@@ -1,11 +1,56 @@
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 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
+ /** 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;
3
36
  export declare function kindFromPath(filePath: string): 'entity' | 'knowledge';
4
- export declare function slugExists(vaultPath: string, slug: string): boolean;
5
- export declare function parseFilePath(path: string): {
6
- frontmatter: Record<string, any>;
7
- body: string;
8
- };
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[];
42
+ /** Full-kind path classification (kindFromPath only distinguishes the
43
+ * indexed kinds — sessions default to knowledge there for compatibility).
44
+ * Session files live directly in sessions/, so only the immediate parent
45
+ * counts — matching anywhere in the path misclassifies every file of a
46
+ * vault that happens to live under a directory named "sessions". */
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;
53
+ export declare function parseFilePath(path: string): ParsedVaultFile;
9
54
  export declare function parseContent(content: string): {
10
55
  frontmatter: Record<string, any>;
11
56
  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, existsSync } 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,22 +67,52 @@ 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`);
71
+ }
72
+ /** The file a slug names, or null — entities are resolved before knowledge,
73
+ * the one resolution order every operation sees. */
74
+ export function findVaultFile(vaultPath, slug) {
75
+ const entity = resolveFilePath(vaultPath, slug, 'entity');
76
+ if (existsSync(entity))
77
+ return entity;
78
+ const knowledge = resolveFilePath(vaultPath, slug, 'knowledge');
79
+ if (existsSync(knowledge))
80
+ return knowledge;
81
+ return null;
82
+ }
83
+ /** Does this slug name a vault file? An invalid slug definitionally doesn't
84
+ * exist — reference checks (create parent/related, link targets, validate)
85
+ * must report "does not exist", not crash. validate() in particular exists to
86
+ * REPORT malformed refs. */
87
+ export function vaultFileExists(vaultPath, slug) {
88
+ try {
89
+ return findVaultFile(vaultPath, slug) !== null;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
30
94
  }
31
95
  // Separator-agnostic: OS paths use '/' or '\' depending on platform, and a
32
96
  // POSIX-only check silently classifies every entity as knowledge on Windows
33
97
  // (all updates then fail on kind rules). Exported pure so win32 paths are
34
98
  // unit-testable from any platform.
99
+ // The DEEPEST kind-dir occurrence wins: the in-vault dir is always deeper
100
+ // than any ancestor, so a vault living under a directory named 'entities'
101
+ // (e.g. ~/data/entities/myvault) can't shadow its knowledge/ files.
35
102
  export function kindFromPath(filePath) {
36
- return /[/\\]entities[/\\]/.test(filePath) ? 'entity' : 'knowledge';
37
- }
38
- export function slugExists(vaultPath, slug) {
39
- return existsSync(resolveFilePath(vaultPath, slug, 'entity'))
40
- || existsSync(resolveFilePath(vaultPath, slug, 'knowledge'));
103
+ const lastMatch = (dir) => {
104
+ let idx = -1;
105
+ for (const m of filePath.matchAll(new RegExp(`[/\\\\]${dir}[/\\\\]`, 'g')))
106
+ idx = m.index;
107
+ return idx;
108
+ };
109
+ return lastMatch(KIND_DIRS.entity) > lastMatch(KIND_DIRS.knowledge) ? 'entity' : 'knowledge';
41
110
  }
42
- // Fields that must always be arrays when present (may be scalar in hand-edited YAML)
43
- const ARRAY_FIELDS = ['related', 'tags', 'domain'];
111
+ /** Fields that must always be arrays when present (may be scalar in
112
+ * hand-edited YAML). Parsing coerces them here; merge.ts unions them on
113
+ * conflict — one list, so a new list-valued field cannot be normalized on
114
+ * read and then overwritten wholesale on merge. */
115
+ export const ARRAY_FIELDS = ['related', 'tags', 'domain'];
44
116
  function normalizeFrontmatter(fm) {
45
117
  for (const field of ARRAY_FIELDS) {
46
118
  if (field in fm && typeof fm[field] === 'string') {
@@ -49,10 +121,41 @@ function normalizeFrontmatter(fm) {
49
121
  }
50
122
  return fm;
51
123
  }
124
+ /** Full-kind path classification (kindFromPath only distinguishes the
125
+ * indexed kinds — sessions default to knowledge there for compatibility).
126
+ * Session files live directly in sessions/, so only the immediate parent
127
+ * counts — matching anywhere in the path misclassifies every file of a
128
+ * vault that happens to live under a directory named "sessions". */
129
+ export function vaultKindFromPath(filePath) {
130
+ if (new RegExp(`[/\\\\]${KIND_DIRS.session}[/\\\\][^/\\\\]+$`).test(filePath))
131
+ return 'session';
132
+ return kindFromPath(filePath);
133
+ }
134
+ /** Total kind classification for a vault-relative content file (anything
135
+ * matching VAULT_FILE_RE) — derived from KIND_DIRS, so adding a vault dir
136
+ * can't silently fall a new kind through to the wrong one. Throws on paths
137
+ * outside the vault dirs; callers gate on VAULT_FILE_RE first. */
138
+ export function vaultKindFromRelPath(relPath) {
139
+ for (const [kind, dir] of Object.entries(KIND_DIRS)) {
140
+ if (relPath.startsWith(`${dir}/`))
141
+ return kind;
142
+ }
143
+ throw new HafezError('VALIDATION_FAILED', `Not a vault content path: ${relPath}`);
144
+ }
52
145
  export function parseFilePath(path) {
53
146
  const content = readFileSync(path, 'utf-8');
54
147
  const { data, content: body } = matter(content, MATTER_OPTIONS);
55
- return { frontmatter: normalizeFrontmatter(data), body: body.trim() };
148
+ const fm = normalizeFrontmatter(data);
149
+ const trimmed = body.trim();
150
+ const kind = vaultKindFromPath(path);
151
+ // Types over classes (C7): the frontmatter is normalized once here and
152
+ // asserted to the kind's shape — validation of required fields stays with
153
+ // schema.ts and the index builder, which tolerate malformed files.
154
+ if (kind === 'entity')
155
+ return { kind, frontmatter: fm, body: trimmed };
156
+ if (kind === 'session')
157
+ return { kind, frontmatter: fm, body: trimmed };
158
+ return { kind: 'knowledge', frontmatter: fm, body: trimmed };
56
159
  }
57
160
  export function parseContent(content) {
58
161
  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.2.0",
3
+ "version": "1.4.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",
package/skill/SKILL.md CHANGED
@@ -1,133 +1,71 @@
1
1
  ---
2
2
  name: hafez
3
3
  description: >
4
- Hafez vault — personal knowledge base of captures, entities, projects,
5
- and knowledge notes (with subtypes: insight, plan). Use when the user says /hafez, asks to query the vault
6
- ("what am I working on?", "show active projects", "what's stale?"), wants to
7
- read or create an entity, or references Hafez directly. Also use at session
8
- start to load context for identifiable topics, and at session end to record
9
- a digest. Even if the user just casually mentions a project by name,
10
- use this skill to check if it's a Hafez entity.
4
+ Hafez vault — personal knowledge base of captures, entities, projects, and
5
+ knowledge notes (subtypes: insight, plan). Use when the user mentions Hafez
6
+ (or types /hafez), asks what's in the vault ("what am I working on?", "what's
7
+ stale?"), wants to create or update an entity or knowledge note, at session
8
+ start to load context for an identifiable topic, at session end to record a
9
+ digest, when a casually mentioned project name might be a vault entity, or
10
+ when the user wants to update the tool itself ("update hafez", "upgrade
11
+ hafez", "is hafez outdated").
11
12
  ---
12
13
 
13
14
  # Hafez — Vault Operations
14
15
 
15
- All operations use the `hafez` CLI. No file manipulation — the CLI handles reads, writes, validation, and git.
16
+ All operations go through the `hafez` CLI — it owns reads, writes, validation, and git. Vault files are never edited directly.
16
17
 
17
18
  If `hafez` is not installed, or `hafez init` reports no vault, stop and follow the setup in the project README (github.com/shrvn-r/hafez) before using this skill. If the vault exists but is empty, run `hafez onboard` and follow what it prints.
18
19
 
19
- ## CLI Reference
20
+ ## Situation → Command
20
21
 
21
- Run `hafez help --agent` for the complete, always-current API reference including all commands, flags, batch schema, and valid enum values.
22
-
23
- Quick reference for common operations:
22
+ `hafez help --agent` is the complete, always-current API reference (all commands, flags, batch schema, enum values). This table covers the common situations; consult `help --agent` for anything beyond it.
24
23
 
25
24
  | Situation | Command |
26
25
  |-----------|---------|
27
- | Read an entity | `hafez read <slug>` |
28
- | Active projects | `hafez query --filter active` |
26
+ | Read an entity (incl. a project the user mentions) | `hafez read <slug>` |
27
+ | "What am I working on?" / active projects | `hafez query --filter active` |
29
28
  | Stale items | `hafez query --filter stale` |
30
- | Full-text search | `hafez search "<terms>"` |
31
- | Search knowledge only | `hafez search "<terms>" --kind knowledge` |
32
- | Create entity | `hafez create entity "<name>" --type project [--purpose "text"] [--description "text"] [--resource "uri"] [--brief "text"] [--add-action "task"]` |
33
- | Quick capture | `hafez capture "<name>"` |
29
+ | Full-text search | `hafez search "<terms>"` (`--kind knowledge` to narrow) |
34
30
  | List inbox captures | `hafez query --filter capture` |
35
- | Update entity | `hafez update <slug> --brief "text" --add-action "task"` |
36
- | Complete action | `hafez update <slug> --complete-action "substring"` |
37
- | Session log | `hafez update <slug> --log "progress: summary" --agent claude` |
38
- | Link entities | `hafez link <slug> <target> --relation related` |
31
+ | Children of a project | `hafez query --parent <slug>` |
32
+ | Related entities/knowledge | `hafez query --related-to <slug>` |
33
+ | New workstream | `hafez create entity "<name>" --type project [--brief "context"] [--add-action "task"]` (full flag list in `help --agent`; `--purpose` is project-only) |
34
+ | New plan | `hafez create knowledge "<name>" --subtype plan [--status active\|done]` (plans have Goal/Steps/Dependencies — no `--synthesis`; `--status` is plan-only) |
35
+ | Quick thought, no classification yet | `hafez capture "<name>"` |
39
36
  | Promote | `hafez promote <slug> <target>` (capture→entity/project/knowledge, entity→project) |
40
- | Add synthesis to knowledge | `hafez update <slug> --synthesis "text"` |
41
- | Add evidence to knowledge | `hafez update <slug> --add-evidence "text"` |
42
- | Add source to knowledge | `hafez update <slug> --add-source "url"` |
43
- | Export OKF bundle | `hafez export --okf [--out <dir>]` (re-export overwrites, never deletes) |
37
+ | Set handoff context / summary / URI | `hafez update <slug> --brief "text"` / `--description "summary"` / `--resource "https://..."` |
38
+ | Add / complete a next action | `hafez update <slug> --add-action "task"` / `--complete-action "substring"` |
39
+ | Significant milestone (deployed, merged, bug found) | `hafez update <slug> --log "progress: summary" --agent claude` |
40
+ | Link entities | `hafez link <slug> <target> --relation related` (or `parent` when a thread belongs to a project) |
41
+ | Revise a knowledge insight | `hafez update <slug> --synthesis "text"` (`--insight` is an alias) |
42
+ | Back knowledge with evidence / a source | `hafez update <slug> --add-evidence "text"` / `--add-source "url"` |
44
43
  | Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` (no pipes? `--file` on both) |
45
44
 
46
- Note: `--insight` is an alias for `--synthesis`.
47
-
48
45
  ## Session Start — Context Loading
49
46
 
50
- When a topic is identifiable from the user's first message:
47
+ Load context only when a specific entity slug or topic is identifiable from the user's first message:
51
48
 
52
49
  1. Run `hafez read <slug>` to load entity context
53
50
  2. If the entity has a `parent`, also read the parent at frontmatter depth: `hafez read <parent> --depth frontmatter`
54
51
  3. Search for related knowledge (`hafez search "<topic>" --kind knowledge`) and read any relevant note: `hafez read <knowledge-slug>`
55
52
  4. Present a brief context line: "Loaded context for {name} (status: {status}, next: {next_action})"
56
53
 
57
- Don't load context speculatively. Only when you can identify a specific entity slug or topic.
58
-
59
- ## Mid-Session — Working With the Vault
60
-
61
- | Situation | Action |
62
- |-----------|--------|
63
- | User asks "what am I working on?" | `hafez query --filter active` |
64
- | User asks about stale items | `hafez query --filter stale` |
65
- | User mentions a project by name | `hafez read <slug>` |
66
- | New workstream emerges | `hafez create entity "<name>" --type project [--brief "context"] [--add-action "task"]` |
67
- | Quick thought, no classification | `hafez capture "<name>"` |
68
- | Promote capture to entity/project | `hafez promote <slug> entity` |
69
- | Insight worth preserving | Search first (`hafez search "<topic>"`), then create or update |
70
- | Entity relates to another | `hafez link <slug> <target> --relation related` |
71
- | Thread belongs to a project | `hafez link <slug> <parent> --relation parent` |
72
- | Knowledge needs new synthesis | `hafez update <slug> --synthesis "text"` |
73
- | Knowledge backed by new evidence | `hafez update <slug> --add-evidence "text"` |
74
- | Knowledge has a source to cite | `hafez update <slug> --add-source "url"` |
75
- | Need children of a project | `hafez query --parent <slug>` |
76
- | Need related entities/knowledge | `hafez query --related-to <slug>` |
77
- | Search for something | `hafez search "<terms>"` |
78
- | Set handoff context | `hafez update <slug> --brief "context"` |
79
- | Set one-line description | `hafez update <slug> --description "summary"` |
80
- | Set resource URI (entity) | `hafez update <slug> --resource "https://..."` |
81
- | Add a next action | `hafez update <slug> --add-action "task"` |
82
- | Complete a next action | `hafez update <slug> --complete-action "task"` |
83
- | Rebuild stale index | `hafez index rebuild` |
84
- | Pull latest from remote | `hafez sync` |
85
-
86
- ### Ingest Reflex — Before Creating New Knowledge
87
-
88
- Before creating a new knowledge note, always check if one already exists:
89
-
90
- ```bash
91
- hafez search "<topic>"
92
- hafez search "<topic>" --kind knowledge
93
- ```
94
-
95
- If a related note exists, update it rather than creating a duplicate. Use `--synthesis` to revise the main insight text, `--add-evidence` to append supporting evidence, or `--add-source` to cite a new source.
96
-
97
- Only create a new note when no existing knowledge covers the topic.
98
-
99
- ### Wiki Links in Knowledge Notes
100
-
101
- When writing or updating knowledge note prose (brief, synthesis, insight body), use `[[wiki links]]` to reference related entities and knowledge naturally. This makes the vault navigable as a wiki:
102
-
103
- - Reference related projects: `[[project-slug]]`
104
- - Reference other knowledge: `[[knowledge-slug]]`
105
- - Reference people or areas: `[[entity-slug]]`
106
-
107
- The CLI does not enforce link syntax — write them naturally in the prose content you pass to `--synthesis`, `--brief`, or `--insight`.
54
+ ## Search-First Ingest
108
55
 
109
- ### Deliberate Ingest Pattern
56
+ A new knowledge note is a last resort. When an insight emerges or a source (document, conversation, research) is being processed:
110
57
 
111
- When processing a source (document, conversation, research) to extract knowledge:
58
+ 1. Search for existing coverage: `hafez search "<topic>"`, narrowing with `--kind knowledge`.
59
+ 2. A note covers the topic → update it: `--synthesis` to revise the main insight, `--add-evidence` to append support, `--add-source` to cite.
60
+ 3. Nothing covers it → create it linked from birth (`hafez create knowledge "<name>" --related slug1,slug2`), then write the body via `--synthesis`.
112
61
 
113
- ```bash
114
- # 1. Search for existing coverage
115
- hafez search "<topic>" --kind knowledge
116
-
117
- # 2a. If exists — update it
118
- hafez update <slug> --synthesis "Revised insight" --add-source "url"
119
- hafez update <slug> --add-evidence "New evidence from source"
120
-
121
- # 2b. If new — create it
122
- hafez create knowledge "<name>" --related slug1,slug2
62
+ Write `[[wiki links]]` in any knowledge prose (`--brief`, `--synthesis`) to reference related projects, entities, and notes — the CLI does not enforce link syntax; write them naturally in the text. This is what keeps the vault navigable as a wiki.
123
63
 
124
- # 3. Then update the note body via --synthesis with wiki links to related notes
125
- hafez update <slug> --synthesis "Insight text referencing [[related-slug]]"
126
- ```
64
+ **Knowledge boundary:** before writing an insight down, ask where it belongs. Useful beyond the current session or repo → vault knowledge note. Specific to one repo's code or workflow → that repo's own docs (CLAUDE.md, README, comments), not the vault.
127
65
 
128
66
  ## Batch Operations
129
67
 
130
- **Rule: Use `hafez batch` when making 2+ mutating calls.** Individual commands auto-commit after each call (and push, when the vault has a remote). Running multiple individual commands back-to-back causes git push races on synced vaults. Batch does all operations in one commit + one push.
68
+ **Rule: Use `hafez batch` when making 2+ mutating calls.** Individual commands auto-commit after each call (and push, when the vault has a remote). Running multiple individual commands back-to-back causes git push races on synced vaults. Batch does all operations in one commit + one push. For a single operation, individual commands are fine — they auto-sync.
131
69
 
132
70
  ```bash
133
71
  echo '[
@@ -147,8 +85,6 @@ On shells that can't pipe stdin to native executables (Windows PowerShell 5.1),
147
85
 
148
86
  **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
87
 
150
- For a single operation, individual commands are fine — they auto-sync.
151
-
152
88
  ## Session End — Digest
153
89
 
154
90
  At the end of a working session (or a natural breakpoint), record what happened:
@@ -173,24 +109,29 @@ Digest adds a session log entry to each touched entity and creates a session not
173
109
 
174
110
  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.
175
111
 
176
- **Mid-session updates and the digest coexist.** Point-in-time updates (`--status done`, `--complete-action`, brief changes) happen as they happen — don't defer them to session end. Digest adds the overall narrative on top. Do NOT hand-write session log entries (`--log`) right before digesting — digest builds the session summary itself. Use `--log` mid-session only for significant milestones (deployed, merged, discovered a bug).
112
+ **Mid-session updates and the digest coexist.** Point-in-time updates (`--status done`, `--complete-action`, brief changes) happen as they happen — digest adds the overall narrative on top. Digest builds the session summary itself, so the session's closing log entry comes from digest; reserve hand-written `--log` for significant mid-session milestones (deployed, merged, discovered a bug).
177
113
 
178
- ## Knowledge Maintenance
114
+ Session end is also when knowledge gets written: review what was learned and run Search-First Ingest on anything worth keeping. Knowledge notes are the primary durable output of sessions — not a side effect.
179
115
 
180
- Knowledge notes are the primary durable output of sessions — not a side effect. Treat the vault as a living wiki.
116
+ ## Periodic Lint
181
117
 
182
- **Active maintenance pattern:**
183
- 1. At session start: search for knowledge relevant to the topic
184
- 2. During work: when insights emerge, search before creating — update existing notes first
185
- 3. Use `[[wiki links]]` in knowledge prose to connect related concepts
186
- 4. At session end: review what was learned and update or create knowledge notes
118
+ Run at session end or on demand:
187
119
 
188
- **Periodic lint** (run at session end or on demand):
189
120
  ```bash
190
121
  hafez validate
191
- hafez query --kind knowledge --filter stale
122
+ hafez query --kind knowledge --sort-by last_reinforced --sort-order asc --limit 20
192
123
  ```
193
- Surface orphaned notes (no related links) and stale knowledge for review or archival.
194
124
 
195
- **Knowledge boundary:** before writing an insight down, ask where it belongs. Useful beyond the current session or repo → vault knowledge note. Specific to one repo's code or workflow → that repo's own docs (CLAUDE.md, README, comments), not the vault.
125
+ Surface orphaned notes (no related links) and the least-recently-reinforced knowledge for review or archival. (`--filter` is entity status — knowledge notes have none, so the query command rejects it under `--kind knowledge`.)
126
+
127
+ ## Updating Hafez Itself
128
+
129
+ "update hafez" means the **tool**. `hafez update` is an entity update — a different thing entirely. Don't confuse them.
130
+
131
+ 1. Run `hafez upgrade`. It checks and prints; it never installs.
132
+ 2. Show the user the printed commands **verbatim** and wait for an explicit yes. They install code and overwrite the user's own agent instructions file — never run them unattended, and never run the tarball alternative alongside the npm install.
133
+ 3. Run the approved steps, then re-run `hafez upgrade` to verify.
134
+
135
+ If `hafez upgrade` fails with the literal prefix `Unknown command:`, the installed hafez predates it. Install the current version, re-copy the bundled skill, then run `hafez upgrade` — the README's Updating section (github.com/shrvn-r/hafez) has the exact commands.
196
136
 
137
+ `hafez upgrade` also prints the current integration snippet. Compare it against the Hafez block in the user's global instructions file and offer to reconcile differences — don't rewrite their file unasked.