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.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +470 -0
- package/dist/batch-ops.js +446 -0
- package/dist/cli/args.d.ts +19 -0
- package/dist/cli/args.js +53 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +24 -108
- package/dist/cli/commands.js +297 -313
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +44 -5
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +172 -172
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/cli.js +0 -0
- package/dist/contracts.d.ts +55 -6
- package/dist/contracts.js +110 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +318 -56
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +117 -0
- package/dist/document.js +578 -0
- package/dist/export-okf.js +48 -34
- package/dist/git.d.ts +7 -8
- package/dist/git.js +198 -63
- package/dist/index.d.ts +3 -4
- package/dist/index.js +302 -915
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +71 -13
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +106 -86
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +86 -12
- package/dist/vault.d.ts +51 -6
- package/dist/vault.js +114 -11
- package/package.json +2 -2
- package/skill/SKILL.md +51 -110
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -167
- package/dist/sections.d.ts +0 -54
- package/dist/sections.js +0 -297
package/dist/types.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
export type
|
|
3
|
-
export type
|
|
4
|
-
export type
|
|
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 =
|
|
7
|
+
export type QueryFilter = EntityStatus | 'all' | 'stale' | 'capture';
|
|
7
8
|
export type LinkRelation = 'parent' | 'related';
|
|
8
|
-
export type KnowledgeSubtype =
|
|
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?:
|
|
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
|
-
|
|
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?:
|
|
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:
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
6
|
-
("what am I working on?", "
|
|
7
|
-
|
|
8
|
-
start to load context for identifiable
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
##
|
|
20
|
+
## Situation → Command
|
|
20
21
|
|
|
21
|
-
|
|
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
|
-
|
|
|
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
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
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
|
-
|
|
|
41
|
-
| Add
|
|
42
|
-
|
|
|
43
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
## Periodic Lint
|
|
181
117
|
|
|
182
|
-
|
|
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 --
|
|
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
|
-
|
|
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.
|