hafez 1.4.0 → 2.0.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 (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +225 -114
  4. package/dist/batch-ops.d.ts +89 -26
  5. package/dist/batch-ops.js +176 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +72 -30
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +13 -4
  26. package/dist/cli/upgrade.js +90 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +44 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +523 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +45 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. package/skill/SKILL.md +0 -137
@@ -0,0 +1,230 @@
1
+ // src/surfaces/surfaces.ts
2
+ //
3
+ // Surfaces of the Agent Contract and how the static ones compose Items. A
4
+ // static Surface is text the binary writes out (the Block, the two skill
5
+ // files); a binary Surface is command output whose Items carry a render
6
+ // function. Every Surface has the label the owner table names it by.
7
+ //
8
+ // Hashes (onboard decision #58): first 12 hex of SHA-256. The Block's begin
9
+ // marker hashes the text between the marker lines; a skill file's `hash:`
10
+ // frontmatter line hashes everything after the closing `---`.
11
+ import { createHash } from 'crypto';
12
+ import { ITEMS } from './items.js';
13
+ export const SURFACES = {
14
+ block: {
15
+ label: 'Block',
16
+ compose: place => [
17
+ '## Hafez',
18
+ '',
19
+ 'Hafez is this user\'s vault: what they are working on (entities) and what they have learned (knowledge). You reach it only through the `hafez` command.',
20
+ '',
21
+ `**Load.** ${place('loadTriggers')}`,
22
+ '',
23
+ '**Write as it happens.** Each of these is due the moment it occurs:',
24
+ '',
25
+ place('writeTriggers'),
26
+ '',
27
+ place('createLines'),
28
+ '',
29
+ '**Rules.**',
30
+ '',
31
+ `- ${place('neverAsk')}`,
32
+ `- ${place('readBeforeWrite')}`,
33
+ `- ${place('oneCommand')}`,
34
+ `- ${place('openActions')}`,
35
+ `- ${place('neverEditByHand')}`,
36
+ `- ${place('neverSaveUnprompted')}`,
37
+ '',
38
+ ].join('\n'),
39
+ },
40
+ hafezSkillDescription: {
41
+ label: '`hafez` skill description',
42
+ compose: () => 'Hafez vault: the less common operations and the tool itself. Use when the user asks what is in the vault ("what am I working on?", "what\'s stale?"), wants to promote, link, unlink, delete, change a status or validate, or wants to update or reconfigure Hafez ("upgrade hafez", "set up hafez"). Also the reference to read when no Hafez block is in your instructions.',
43
+ },
44
+ hafezSkillBody: {
45
+ label: '`hafez` skill body',
46
+ compose: place => [
47
+ '',
48
+ '# Hafez',
49
+ '',
50
+ '- Update an entity or note: `hafez read <slug>`. The end of its output shows the write command.',
51
+ '- Create an entity or a knowledge note: the two `hafez create` lines below.',
52
+ '- Save the session: the `hafez-save` skill. No skill: `hafez schema digest`.',
53
+ '',
54
+ place('neverAsk'),
55
+ place('readBeforeWrite'),
56
+ place('openActions'),
57
+ '',
58
+ '## Create',
59
+ '',
60
+ place('createLines'),
61
+ '',
62
+ '## Find',
63
+ '',
64
+ place('find'),
65
+ '',
66
+ '## Less common operations',
67
+ '',
68
+ place('rareOps'),
69
+ '',
70
+ `${place('batchShape')} Machine-readable shape of any op: \`hafez schema <op>\`, with \`--examples\` for a sample.`,
71
+ '',
72
+ '## The tool itself',
73
+ '',
74
+ place('upgradeReconfigure'),
75
+ '',
76
+ ].join('\n'),
77
+ },
78
+ hafezSaveDescription: {
79
+ label: '`hafez-save` description',
80
+ compose: place => place('saveTrigger'),
81
+ },
82
+ hafezSaveBody: {
83
+ label: '`hafez-save` body',
84
+ compose: place => ['', '# Save the session', '', place('saveMechanics'), ''].join('\n'),
85
+ },
86
+ read: { label: 'Binary: `read`' },
87
+ readHeader: { label: 'Binary: `read` header' },
88
+ readFooter: { label: 'Binary: `read` Footer' },
89
+ readMiss: { label: 'Binary: `read` miss' },
90
+ updateSuccess: { label: 'Binary: `update` success output' },
91
+ updateMisuse: { label: 'Binary: `update` misuse error' },
92
+ createKnowledge: { label: 'Binary: `create knowledge` output' },
93
+ digestError: { label: 'Binary: `digest` error' },
94
+ batchMisuse: { label: 'Binary: `batch` misuse error' },
95
+ mechanised: { label: 'Binary, mechanised' },
96
+ schema: { label: '`schema`' },
97
+ onboard: { label: '`onboard`' },
98
+ };
99
+ export const contentHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
100
+ /** Compose a static Surface, placing each Item's text in the form its role
101
+ * (Owner or fallback) calls for. `onPlace` sees every placement, nested ones
102
+ * included. */
103
+ function composeWith(items, surfaceId, surface, onPlace = () => { }) {
104
+ const place = (id) => {
105
+ onPlace(id);
106
+ const item = items[id];
107
+ if (!item)
108
+ throw new Error(`${surfaceId} places unknown Item ${id}`);
109
+ const text = item.text;
110
+ if (typeof text === 'function')
111
+ return text(place, item.owner === surfaceId ? 'owner' : 'fallback');
112
+ if (text === undefined)
113
+ throw new Error(`${surfaceId} places ${id}, which has no text`);
114
+ return text;
115
+ };
116
+ if (!surface.compose)
117
+ throw new Error(`${surfaceId} is not a static surface`);
118
+ return surface.compose(place);
119
+ }
120
+ const compose = (id) => composeWith(ITEMS, id, SURFACES[id]);
121
+ /** An Item's text as a binary Surface places it at run time, in the form its
122
+ * role there (Owner or fallback) calls for. */
123
+ export function placeIn(surfaceId, id) {
124
+ const item = ITEMS[id];
125
+ if (surfaceId !== item.owner && surfaceId !== item.fallback) {
126
+ throw new Error(`${surfaceId} places ${id}, which is neither its owner nor its fallback`);
127
+ }
128
+ return composeWith(ITEMS, surfaceId, { label: SURFACES[surfaceId].label, compose: place => place(id) });
129
+ }
130
+ /** YAML folded scalar lines: greedy wrap at 76 columns, two-space indent. */
131
+ function fold(text) {
132
+ const lines = [];
133
+ let line = '';
134
+ for (const word of text.split(' ')) {
135
+ if (line && 2 + line.length + 1 + word.length > 76) {
136
+ lines.push(line);
137
+ line = word;
138
+ }
139
+ else {
140
+ line = line ? `${line} ${word}` : word;
141
+ }
142
+ }
143
+ lines.push(line);
144
+ return lines.map(l => ` ${l}`).join('\n');
145
+ }
146
+ function skillFile(name, description, body) {
147
+ const text = compose(body);
148
+ return `---\nname: ${name}\ndescription: >\n${fold(compose(description))}\nhash: ${contentHash(text)}\n---\n${text}`;
149
+ }
150
+ export function renderBlock() {
151
+ const inner = compose('block');
152
+ return `<!-- hafez:begin ${contentHash(inner)} -->\n${inner}<!-- hafez:end -->\n`;
153
+ }
154
+ /** `onboard --block`, what the SessionStart hook prints: the Block, then the
155
+ * read line for each entity matching the folder. */
156
+ export function renderBlockHook(matches) {
157
+ return matches.length === 0 ? renderBlock() : `${renderBlock()}\n${ITEMS.folderMatch.render(matches)}\n`;
158
+ }
159
+ export const renderHafezSkill = () => skillFile('hafez', 'hafezSkillDescription', 'hafezSkillBody');
160
+ /** `help --agent`: the `hafez` skill body, the one text, without the blank
161
+ * line that parts it from the skill file's frontmatter or the newline that
162
+ * ends the file (the CLI adds one to every output). */
163
+ export const renderAgentHelp = () => compose('hafezSkillBody').replace(/^\n/, '').replace(/\n$/, '');
164
+ export const renderHafezSaveSkill = () => skillFile('hafez-save', 'hafezSaveDescription', 'hafezSaveBody');
165
+ // --- The owner table, rendered into a marked region of the contract README ---
166
+ export const OWNER_TABLE_BEGIN = '<!-- owner-table:begin (rendered from src/surfaces/items.ts; edit the Item, then run scripts/render-surfaces.ts) -->';
167
+ export const OWNER_TABLE_END = '<!-- owner-table:end -->';
168
+ export function renderOwnerTable() {
169
+ const rows = Object.values(ITEMS).map((item) => {
170
+ const fallback = item.fallback ? SURFACES[item.fallback].label : '—';
171
+ return `| ${item.label} | ${SURFACES[item.owner].label} | ${fallback} | ${item.invokeOnly} |`;
172
+ });
173
+ return ['| Item | Owner | Fallback | Invoke-only |', '|---|---|---|---|', ...rows, ''].join('\n');
174
+ }
175
+ /** Replace the marked owner-table region of `doc` with a fresh render. */
176
+ export function spliceOwnerTable(doc) {
177
+ const begin = doc.indexOf(OWNER_TABLE_BEGIN);
178
+ const end = doc.indexOf(OWNER_TABLE_END, begin);
179
+ if (begin < 0 || end < 0)
180
+ throw new Error('owner table markers missing or out of order');
181
+ if (doc.indexOf(OWNER_TABLE_BEGIN, begin + 1) >= 0 || doc.indexOf(OWNER_TABLE_END, end + 1) >= 0) {
182
+ throw new Error('owner table markers appear more than once');
183
+ }
184
+ return doc.slice(0, begin + OWNER_TABLE_BEGIN.length) + '\n' + renderOwnerTable() + doc.slice(end);
185
+ }
186
+ // --- Drift check 2: the typed Item table ---
187
+ /** Every problem with the Item table: an Item with no Owner or no evidence
188
+ * pointer, an Item its Owner or static fallback never places, or one placed more than once in a
189
+ * Surface or in a Surface that is neither its Owner nor its declared fallback.
190
+ * Placement is checked for static Surfaces only; binary ones place at run time. */
191
+ export function itemProblems(items, surfaces) {
192
+ const problems = [];
193
+ const placed = {};
194
+ for (const [sid, surface] of Object.entries(surfaces)) {
195
+ if (!surface.compose)
196
+ continue;
197
+ const counts = (placed[sid] = {});
198
+ try {
199
+ composeWith(items, sid, surface, id => { counts[id] = (counts[id] ?? 0) + 1; });
200
+ }
201
+ catch (err) {
202
+ problems.push(err.message);
203
+ }
204
+ }
205
+ for (const [id, item] of Object.entries(items)) {
206
+ if (!item.owner)
207
+ problems.push(`${id}: no owner`);
208
+ else if (!surfaces[item.owner])
209
+ problems.push(`${id}: unknown owner ${item.owner}`);
210
+ if (item.fallback && !surfaces[item.fallback])
211
+ problems.push(`${id}: unknown fallback ${item.fallback}`);
212
+ if (!item.evidence?.trim())
213
+ problems.push(`${id}: no evidence pointer`);
214
+ for (const [sid, counts] of Object.entries(placed)) {
215
+ const n = counts[id] ?? 0;
216
+ if (n > 0 && sid !== item.owner && sid !== item.fallback) {
217
+ problems.push(`${id}: placed in ${sid}, which is neither its owner nor its fallback`);
218
+ }
219
+ if (n > 1)
220
+ problems.push(`${id}: placed ${n} times in ${sid}`);
221
+ }
222
+ if (item.owner && surfaces[item.owner]?.compose && !placed[item.owner]?.[id]) {
223
+ problems.push(`${id}: not placed in its owner ${item.owner}`);
224
+ }
225
+ if (item.fallback && surfaces[item.fallback]?.compose && !placed[item.fallback]?.[id]) {
226
+ problems.push(`${id}: not placed in its fallback ${item.fallback}`);
227
+ }
228
+ }
229
+ return problems;
230
+ }
package/dist/types.d.ts CHANGED
@@ -9,18 +9,23 @@ export type LinkRelation = 'parent' | 'related';
9
9
  export type KnowledgeSubtype = typeof VALID_SUBTYPES[number];
10
10
  /** Plans are the one knowledge subtype with a lifecycle (see PLAN_STATUSES). */
11
11
  export type PlanStatus = typeof PLAN_STATUSES[number];
12
+ /** Sort fields, and the two an entity query cannot honour — reinforcement
13
+ * lives on knowledge notes only. Asking for one on the entity branch is a
14
+ * validation error, not a silent fall back to recency, so the entity opts
15
+ * type declares only what that branch can actually sort by. */
16
+ export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
17
+ export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count'>;
12
18
  export interface EntityQueryOpts {
13
19
  filter?: QueryFilter;
14
20
  type?: EntityType;
15
21
  parent?: string;
16
22
  domain?: string;
17
- confidence?: ConfidenceLevel;
18
23
  since?: string;
19
24
  before?: string;
20
25
  createdSince?: string;
21
26
  createdBefore?: string;
22
27
  tags?: string[];
23
- sort_by?: 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
28
+ sort_by?: EntitySortField;
24
29
  sort_order?: 'asc' | 'desc';
25
30
  limit?: number;
26
31
  offset?: number;
@@ -34,18 +39,31 @@ export interface KnowledgeQueryOpts {
34
39
  createdSince?: string;
35
40
  createdBefore?: string;
36
41
  tags?: string[];
37
- sort_by?: 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
42
+ sort_by?: SortField;
38
43
  sort_order?: 'asc' | 'desc';
39
44
  limit?: number;
40
45
  offset?: number;
41
46
  }
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
+ /** `kind: 'all'` (or unset): reaches both sides, so it takes opts from both
48
+ * and can honour every sort field. Status filters narrow the entity side only
49
+ * — knowledge notes carry no status and are never excluded by one. */
50
+ export interface AllQueryOpts extends Omit<EntityQueryOpts, 'sort_by'> {
51
+ kind?: 'all';
52
+ confidence?: ConfidenceLevel;
47
53
  subtype?: KnowledgeSubtype;
54
+ sort_by?: SortField;
48
55
  }
56
+ /** The unified query, discriminated on `kind`. Naming a single kind narrows
57
+ * this query to that kind's opts, because a narrowed branch maps no more than
58
+ * the dedicated one does: an out-of-scope condition would land on a column the
59
+ * selected kind leaves NULL and silently exclude every row. The read surface
60
+ * rejects those at runtime for callers without types; the union is what makes
61
+ * them a compile error first. */
62
+ export type UnifiedQueryOpts = AllQueryOpts | (EntityQueryOpts & {
63
+ kind: 'entity';
64
+ }) | (KnowledgeQueryOpts & {
65
+ kind: 'knowledge';
66
+ });
49
67
  export interface EntityFrontmatter {
50
68
  name: string;
51
69
  type: EntityType;
@@ -96,12 +114,11 @@ export type ParsedVaultFile = {
96
114
  frontmatter: SessionFrontmatter;
97
115
  body: string;
98
116
  };
99
- export interface ParsedFile<T = EntityFrontmatter | KnowledgeFrontmatter> {
100
- /** Populated by read(): which side of the vault the slug resolved to. */
101
- kind?: 'entity' | 'knowledge';
102
- frontmatter: T;
103
- body: string;
104
- }
117
+ /** What read() returns: the indexed arms of ParsedVaultFile (a session slug is
118
+ * never resolvable through read()), so consumers narrow on `kind`. */
119
+ export type ParsedFile = Extract<ParsedVaultFile, {
120
+ kind: 'entity' | 'knowledge';
121
+ }>;
105
122
  export interface SessionLogEntry {
106
123
  type: SessionLogType;
107
124
  summary: string;
@@ -119,12 +136,14 @@ export interface UpdateFields {
119
136
  status?: EntityStatus;
120
137
  current_state?: string;
121
138
  session_log?: SessionLogEntry;
139
+ agent?: string;
122
140
  brief?: string | null;
123
141
  add_action?: string;
124
142
  add_actions?: string[];
125
143
  complete_action?: string;
126
144
  remove_action?: string;
127
145
  clear_actions?: boolean;
146
+ parent?: string;
128
147
  description?: string | null;
129
148
  resource?: string | null;
130
149
  domain?: string[];
@@ -137,7 +156,9 @@ export interface UpdateFields {
137
156
  add_source?: string;
138
157
  }
139
158
  export interface UpdateResult {
140
- matched_action?: string;
159
+ completed_action?: string;
160
+ removed_action?: string;
161
+ logged?: true;
141
162
  }
142
163
  export interface CreateEntityFields {
143
164
  type: EntityType;
@@ -155,6 +176,7 @@ export interface CreateEntityFields {
155
176
  export interface CreateKnowledgeFields {
156
177
  subtype?: KnowledgeSubtype;
157
178
  status?: PlanStatus;
179
+ confidence?: ConfidenceLevel;
158
180
  synthesis?: string;
159
181
  insight?: string;
160
182
  description?: string;
@@ -208,7 +230,6 @@ export interface ValidationIssue {
208
230
  export interface ValidationReport {
209
231
  broken_slugs: ValidationIssue[];
210
232
  orphaned_knowledge: string[];
211
- oversized_related: ValidationIssue[];
212
233
  missing_fields: ValidationIssue[];
213
234
  total_entities: number;
214
235
  total_knowledge: number;
@@ -307,7 +328,13 @@ export type BatchOperation = {
307
328
  op: 'promote';
308
329
  slug: string;
309
330
  target: 'entity' | 'project' | 'knowledge';
331
+ } | {
332
+ op: 'delete';
333
+ slug: string;
310
334
  };
335
+ /** What `delete` may remove. An entity is never deleted: it is retired with
336
+ * `status: done`. */
337
+ export type DeletableKind = 'knowledge' | 'session';
311
338
  export interface BatchOpValidation {
312
339
  index: number;
313
340
  op: string;
@@ -380,6 +407,8 @@ export interface Hafez {
380
407
  link(slug: string, target: string, relation: LinkRelation): Promise<void>;
381
408
  unlink(slug: string, target: string, relation: LinkRelation): Promise<void>;
382
409
  promote(slug: string, target: 'entity' | 'project' | 'knowledge'): Promise<string>;
410
+ /** Delete a knowledge or session note; resolves to the kind deleted. */
411
+ delete(slug: string): Promise<DeletableKind>;
383
412
  batch(operations: BatchOperation[]): Promise<BatchResult[]>;
384
413
  validateBatch(operations: BatchOperation[]): Promise<BatchValidationReport>;
385
414
  sync(): Promise<{
@@ -0,0 +1,7 @@
1
+ import type { ValidationReport } from './types.js';
2
+ /** The vault integrity scan. Deliberately independent of the SQLite index:
3
+ * this is the one operation that does not trust the index, so it reads the
4
+ * files directly. Re-implementing it as an index query was considered and
5
+ * rejected — an integrity checker that reads the index is checking the
6
+ * index's model of the vault, not the vault. */
7
+ export declare function validateVault(vaultPath: string): ValidationReport;
@@ -0,0 +1,109 @@
1
+ import { join, basename } from 'path';
2
+ import { parseFilePath, scanVaultDir, kindDir, vaultFileExists } from './vault.js';
3
+ import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
4
+ import { parentCycle } from './batch-ops.js';
5
+ /** The vault integrity scan. Deliberately independent of the SQLite index:
6
+ * this is the one operation that does not trust the index, so it reads the
7
+ * files directly. Re-implementing it as an index query was considered and
8
+ * rejected — an integrity checker that reads the index is checking the
9
+ * index's model of the vault, not the vault. */
10
+ export function validateVault(vaultPath) {
11
+ const exists = (slug) => vaultFileExists(vaultPath, slug);
12
+ const report = {
13
+ broken_slugs: [],
14
+ orphaned_knowledge: [],
15
+ missing_fields: [],
16
+ total_entities: 0,
17
+ total_knowledge: 0,
18
+ };
19
+ const knowledgeSlugs = new Set();
20
+ const referencedKnowledge = new Set();
21
+ const parents = new Map();
22
+ // Scan entities
23
+ const entDir = join(vaultPath, kindDir('entity'));
24
+ {
25
+ const files = scanVaultDir(entDir);
26
+ report.total_entities = files.length;
27
+ for (const filePath of files) {
28
+ const slug = basename(filePath, '.md');
29
+ let fm;
30
+ try {
31
+ fm = parseFilePath(filePath).frontmatter;
32
+ }
33
+ catch {
34
+ report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
35
+ continue;
36
+ }
37
+ const errors = validateEntityFrontmatter(fm);
38
+ for (const e of errors)
39
+ report.missing_fields.push({ slug, field: '', issue: e });
40
+ if (fm.parent) {
41
+ parents.set(slug, fm.parent);
42
+ if (!exists(fm.parent))
43
+ report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
44
+ }
45
+ if (fm.related && Array.isArray(fm.related)) {
46
+ for (const r of fm.related) {
47
+ if (!exists(r))
48
+ report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
49
+ // Track referenced knowledge
50
+ referencedKnowledge.add(r);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ // Parent cycles: reported once each, under the first member found
56
+ const inCycle = new Set();
57
+ for (const [slug, parent] of parents) {
58
+ if (inCycle.has(slug))
59
+ continue;
60
+ const cycle = parentCycle(slug, parent, s => parents.get(s));
61
+ if (!cycle)
62
+ continue;
63
+ for (const s of cycle)
64
+ inCycle.add(s);
65
+ report.broken_slugs.push({ slug, field: 'parent', issue: `parent cycle: ${cycle.join(' → ')}` });
66
+ }
67
+ // Scan knowledge
68
+ const knDir = join(vaultPath, kindDir('knowledge'));
69
+ {
70
+ const files = scanVaultDir(knDir);
71
+ report.total_knowledge = files.length;
72
+ for (const filePath of files) {
73
+ const slug = basename(filePath, '.md');
74
+ knowledgeSlugs.add(slug);
75
+ let fm;
76
+ try {
77
+ fm = parseFilePath(filePath).frontmatter;
78
+ }
79
+ catch {
80
+ report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
81
+ continue;
82
+ }
83
+ const errors = validateKnowledgeFrontmatter(fm);
84
+ for (const e of errors)
85
+ report.missing_fields.push({ slug, field: '', issue: e });
86
+ if (fm.related && Array.isArray(fm.related)) {
87
+ for (const r of fm.related) {
88
+ if (!exists(r))
89
+ report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
90
+ }
91
+ }
92
+ }
93
+ }
94
+ // Find orphaned knowledge: no related, and not referenced by any entity
95
+ for (const slug of knowledgeSlugs) {
96
+ let fm;
97
+ try {
98
+ fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
99
+ }
100
+ catch {
101
+ continue; // already reported above
102
+ }
103
+ const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
104
+ if (!hasRelated && !referencedKnowledge.has(slug)) {
105
+ report.orphaned_knowledge.push(slug);
106
+ }
107
+ }
108
+ return report;
109
+ }
package/dist/vault.js CHANGED
@@ -11,7 +11,7 @@ export function slugify(name) {
11
11
  // real slug instead of collapsing to ''.
12
12
  return name
13
13
  .toLowerCase()
14
- .replace(/['']/g, '')
14
+ .replace(/['’]/g, '')
15
15
  .replace(/[^\p{L}\p{N}]+/gu, '-')
16
16
  .replace(/^-|-$/g, '')
17
17
  .replace(/-{2,}/g, '-');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hafez",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -18,8 +18,9 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
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
- "pretest": "npm run build",
21
+ "install:local": "chmod +x \"$(pwd)/dist/cli.js\" && mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && for d in ~/.claude/skills ~/.agents/skills; do for s in hafez hafez-save; do mkdir -p \"$d/$s\" && cp \"$(pwd)/skill/$s/SKILL.md\" \"$d/$s/SKILL.md\"; done; done",
22
+ "typecheck": "tsc -p tsconfig.test.json",
23
+ "pretest": "npm run build && npm run typecheck",
23
24
  "test": "vitest run",
24
25
  "test:watch": "vitest",
25
26
  "prepare": "npm run build"
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: hafez
3
+ description: >
4
+ Hafez vault: the less common operations and the tool itself. Use when the
5
+ user asks what is in the vault ("what am I working on?", "what's stale?"),
6
+ wants to promote, link, unlink, delete, change a status or validate, or
7
+ wants to update or reconfigure Hafez ("upgrade hafez", "set up hafez").
8
+ Also the reference to read when no Hafez block is in your instructions.
9
+ hash: 01a30ae1e209
10
+ ---
11
+
12
+ # Hafez
13
+
14
+ - Update an entity or note: `hafez read <slug>`. The end of its output shows the write command.
15
+ - Create an entity or a knowledge note: the two `hafez create` lines below.
16
+ - Save the session: the `hafez-save` skill. No skill: `hafez schema digest`.
17
+
18
+ Never ask the user whether to write to the vault. Write, then say so in one line naming the slug and what you did.
19
+ Update only what you have read this session. Not read yet: read it first.
20
+ Open actions on an entity you read are yours: tick what landed, drop what is dead. Lines `read` marks stale are due.
21
+
22
+ ## Create
23
+
24
+ ```
25
+ hafez create entity "<name>" --type <project|entity> --description "<one sentence>" --brief "<where things stand>" --add-action "<next step>"
26
+ hafez create knowledge "<name>" --synthesis "<the insight>" --confidence <observation|pattern|principle> --related <slug>
27
+ ```
28
+
29
+ A plan is `--subtype plan` without `--synthesis`. Cannot tell entity from knowledge: `hafez capture "<name>"`.
30
+
31
+ ## Find
32
+
33
+ ```
34
+ hafez query --filter <active|paused|done|stale|capture>
35
+ hafez search "<terms>" --kind <entity|knowledge>
36
+ hafez stats
37
+ ```
38
+
39
+ ## Less common operations
40
+
41
+ ```
42
+ hafez promote <slug> <entity|project|knowledge>
43
+ hafez link <slug> <target> --relation <parent|related>
44
+ hafez unlink <slug> <target> --relation <parent|related>
45
+ hafez update <slug> --status <active|paused|done>
46
+ hafez delete <slug>
47
+ hafez validate
48
+ ```
49
+
50
+ A plan's status is `active|done`. `delete` removes a knowledge or session note only; an entity is retired with `--status done`.
51
+
52
+ Several writes that must land together go in one batch: a bare array, no wrapper. A slug is the name lowercased, apostrophes dropped, each other run of characters that are not letters or digits turned into one `-` and none left at either end, so a later op can name what an earlier one creates.
53
+
54
+ ```
55
+ hafez batch --data '[
56
+ {"op":"create","kind":"entity","name":"Token Rotation","fields":{"type":"entity"}},
57
+ {"op":"link","slug":"token-rotation","target":"auth-refactor","relation":"parent"}
58
+ ]'
59
+ ```
60
+
61
+ In the JSON, write each apostrophe as `’` (`isn’t`): a straight `'` ends the quoted argument and the batch fails.
62
+
63
+ No pipe (PowerShell 5.1): `hafez batch --file ops.json`. Machine-readable shape of any op: `hafez schema <op>`, with `--examples` for a sample.
64
+
65
+ ## The tool itself
66
+
67
+ - Upgrade: `hafez upgrade`. It checks and prints, it never installs. Show the user the printed steps and run them only after a yes.
68
+ - Set up or reconfigure: `hafez onboard`.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: hafez-save
3
+ description: >
4
+ Save this session to the Hafez vault. Use when the user invokes hafez-save
5
+ or says "save", "save the session" or "wrap up". Never run it unprompted.
6
+ hash: 91351681e779
7
+ ---
8
+
9
+ # Save the session
10
+
11
+ 1. List the slugs you read, updated or created this session. Use only those; a slug you did not see in `hafez` output this session does not go in. No slugs: tell the user there is nothing to save and stop.
12
+ 2. Run this once, with today's date:
13
+
14
+ ```
15
+ hafez digest --data '{
16
+ "entities_touched": ["<slug>", "<slug>"],
17
+ "decisions": ["<a decision made this session>"],
18
+ "narrative": "<one to three sentences: what happened and how it connects>",
19
+ "session_date": "<YYYY-MM-DD>"
20
+ }' | hafez batch
21
+ ```
22
+
23
+ In the JSON, write each apostrophe as `’` (`isn’t`): a straight `'` ends the quoted argument and the save fails.
24
+
25
+ No pipe (PowerShell 5.1): put the JSON in `summary.json`, then `hafez digest --file summary.json > ops.json`, then `hafez batch --file ops.json`.
26
+
27
+ 3. Tell the user in one line: the session note's slug and the entities it logged to.
28
+
29
+ `decisions` may be an empty array. Each slug in `entities_touched` gets a session log entry and the session note links them all. An unknown slug fails the save and names the slug: fix the list and run it again. A session already saved is skipped, so a second run does no harm.
@@ -1 +0,0 @@
1
- export declare function renderAgentHelp(): string;