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.
- package/README.md +26 -33
- package/dist/apply.d.ts +5 -2
- package/dist/apply.js +225 -114
- package/dist/batch-ops.d.ts +89 -26
- package/dist/batch-ops.js +176 -18
- package/dist/cli/args.d.ts +43 -5
- package/dist/cli/args.js +69 -8
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +4 -3
- package/dist/cli/commands.js +300 -99
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +18 -4
- package/dist/cli/index.js +72 -30
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +13 -4
- package/dist/cli/upgrade.js +90 -44
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +17 -1
- package/dist/contracts.js +44 -0
- package/dist/db.d.ts +15 -2
- package/dist/db.js +69 -48
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +41 -3
- package/dist/document.js +218 -62
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +11 -7
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -104
- package/dist/merge.js +7 -5
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/types.d.ts +45 -16
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.js +1 -1
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -210
- 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?:
|
|
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?:
|
|
42
|
+
sort_by?: SortField;
|
|
38
43
|
sort_order?: 'asc' | 'desc';
|
|
39
44
|
limit?: number;
|
|
40
45
|
offset?: number;
|
|
41
46
|
}
|
|
42
|
-
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
export interface
|
|
46
|
-
kind?: '
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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;
|
package/dist/validate.js
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hafez",
|
|
3
|
-
"version": "
|
|
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 &&
|
|
22
|
-
"
|
|
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.
|
package/dist/cli/help-agent.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function renderAgentHelp(): string;
|